Skip to main content

speccoder_runtime/
progress.rs

1//! Live progress: what a run is doing while it does it.
2//!
3//! Events are a side channel — not journaled, not evidence — so replay and
4//! verdicts are untouched. [`Live`] keeps `live.json` in the project's state
5//! directory up to date (read by `speccoder report --live` from another terminal),
6//! prices token counts when `[model]` has prices, and forwards each event to
7//! the CLI's printer.
8
9use 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
18/// The live file in a project's state directory.
19pub const FILE: &str = "live.json";
20
21/// One task of a feature run's plan.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct PlannedTask {
24    /// Task ID.
25    pub id: String,
26    /// Title.
27    pub title: String,
28    /// `done`, `ready`, `blocked` or `skipped`.
29    pub state: String,
30}
31
32/// Tool calls `live.json` keeps for watchers (`speccoder tui`).
33pub const RECENT: usize = 12;
34
35/// Something a run did.
36#[derive(Debug, Clone, PartialEq, Serialize)]
37#[serde(tag = "event", rename_all = "snake_case")]
38pub enum Event {
39    /// A feature run begins: its tasks, as the graph has them (editors show
40    /// them as a plan).
41    RunStarted {
42        /// Feature directory name.
43        feature: String,
44        /// Every task, in document order.
45        tasks: Vec<PlannedTask>,
46    },
47    /// A task attempt run begins.
48    TaskStarted {
49        /// Feature directory name.
50        feature: String,
51        /// Task ID.
52        task: String,
53        /// Task title.
54        title: String,
55    },
56    /// A stage of the task's pipeline began, ended or does not apply.
57    Stage {
58        /// Task ID.
59        task: String,
60        /// Which stage.
61        stage: Stage,
62        /// Where it is.
63        state: StageState,
64        /// `1/2 · sample 2/3` for an attempt, a short commit id, a reason.
65        detail: Option<String>,
66        /// How long it took, once it ended.
67        elapsed_ms: Option<u64>,
68        /// The last lines of a failed check (at most five).
69        tail: Vec<String>,
70    },
71    /// A run picks up an attempt a killed process left behind.
72    Resumed {
73        /// Task ID.
74        task: String,
75        /// The stage it was in, when known.
76        stage: Option<Stage>,
77        /// The last tool call, when known.
78        last_call: Option<String>,
79    },
80    /// A call waits for a person: notify them.
81    ApprovalRequested {
82        /// Task ID.
83        task: String,
84        /// The model's call id.
85        call: String,
86        /// The tool.
87        tool: String,
88        /// What waits, e.g. `cargo add x` (no control characters, ≤ 60 chars).
89        what: String,
90        /// The class of the object a decision covers: `write`, `command`,
91        /// `network` or `mcp` (empty: none, the call is decided once).
92        class: String,
93        /// The object's short form: the path, `argv#<12 hex>`,
94        /// `<tool>@<12 hex>` — no argv beyond what `what` shows.
95        object: String,
96    },
97    /// A person decided a call inline.
98    ApprovalDecided {
99        /// Task ID.
100        task: String,
101        /// The model's call id.
102        call: String,
103        /// `approve` or `reject`.
104        decision: String,
105        /// `once`, `task` or `run`, for an approval.
106        scope: Option<String>,
107        /// Who decided.
108        by: String,
109        /// How that is known: `asserted`, `connected` (or `expired`).
110        how: String,
111        /// The object's class, as for [`Event::ApprovalRequested`].
112        class: String,
113        /// The object's short form.
114        object: String,
115    },
116    /// A tool call the model asked for, with the policy's decision.
117    ToolCall {
118        /// Task ID.
119        task: String,
120        /// Model turn (1-based) within the attempt.
121        turn: u32,
122        /// The tool.
123        tool: String,
124        /// Its target: a path, a command, a pattern.
125        target: String,
126        /// `allow`, `deny`, `approval`, `unknown`, `stop`.
127        decision: String,
128        /// Whether an allowed call succeeded.
129        ok: Option<bool>,
130        /// The last lines of a failed command (at most five).
131        tail: Vec<String>,
132    },
133    /// Tokens so far in the current attempt.
134    Usage {
135        /// Task ID.
136        task: String,
137        /// Input tokens.
138        input_tokens: u64,
139        /// Output tokens.
140        output_tokens: u64,
141        /// Input tokens read from the cache.
142        cache_read_tokens: u64,
143        /// Input tokens written to the cache.
144        cache_write_tokens: u64,
145        /// USD, when priced.
146        usd: Option<f64>,
147    },
148    /// The task run ended.
149    TaskFinished {
150        /// Task ID.
151        task: String,
152        /// `verified`, `regression_only`, `failed`, `blocked`, …
153        outcome: String,
154        /// The run's message.
155        message: String,
156        /// The attempt's agentplane run, when one ran.
157        run: Option<String>,
158        /// Input tokens, all attempts and the acceptance call.
159        input_tokens: u64,
160        /// Output tokens.
161        output_tokens: u64,
162        /// Input tokens read from the cache.
163        cache_read_tokens: u64,
164        /// Input tokens written to the cache.
165        cache_write_tokens: u64,
166        /// USD, when priced.
167        usd: Option<f64>,
168        /// A regression-only tick: why, worded.
169        regression: Option<speccoder_core::regression::Regression>,
170    },
171    /// The attempt's changed test files were checked against what the run
172    /// collected (`collection.checked`).
173    CollectionChecked {
174        /// Task ID.
175        task: String,
176        /// Each judged file: path, state, what decided it.
177        files: Vec<CollectionFile>,
178    },
179    /// The bisection for an already-passing tick ended (`regression.bisected`).
180    RegressionBisected {
181        /// Task ID.
182        task: String,
183        /// Acceptance runs it took.
184        runs: u32,
185        /// The task that made the tests pass, `before_feature`, or `null`.
186        result: Option<String>,
187    },
188    /// What the model said in a turn, beside its tool calls: its
189    /// account of what it is doing — a claim, never evidence. Control
190    /// characters escaped; at most 2000 characters.
191    Said {
192        /// Task ID.
193        task: String,
194        /// Model turn (1-based) within the attempt.
195        turn: u32,
196        /// The words.
197        text: String,
198    },
199    /// The model's summary of its reasoning in a turn, as the provider
200    /// gave it (`[model] reasoning_summary`): its account of why — a claim,
201    /// never evidence. Control characters escaped; at most 4000 characters.
202    Reasoning {
203        /// Task ID.
204        task: String,
205        /// Model turn (1-based) within the attempt.
206        turn: u32,
207        /// The summary.
208        text: String,
209    },
210    /// Anything else worth a line (convergence rounds, notices).
211    Notice {
212        /// The line.
213        text: String,
214    },
215    /// A bug's step (`bug.*` on the event stream).
216    Bug {
217        /// The task (`T003`, `bug:<slug>`).
218        task: String,
219        /// What happened.
220        step: BugEvent,
221    },
222    /// The oracle at review (`oracle.*` on the event stream): stage names,
223    /// counts and digests — never a test's content, never held-out material.
224    Oracle {
225        /// What happened.
226        step: OracleEvent,
227    },
228    /// Plan through stopped (`planning.stopped`).
229    PlanningStopped {
230        /// The unit, once it exists.
231        unit: Option<String>,
232        /// `questions`, `review`, `failed`, `done`.
233        kind: String,
234        /// The skill a continuation runs next.
235        next_skill: Option<String>,
236    },
237    /// A new project's step, its creation or its undo (`scaffold.*`,
238    /// `project.*`): step ids, argv, states, digests — never the idea,
239    /// never the folder's path.
240    Project {
241        /// What happened.
242        step: ProjectEvent,
243    },
244}
245
246/// A new project's step, its creation or its undo.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
248#[serde(tag = "event", rename_all = "snake_case")]
249pub enum ProjectEvent {
250    /// A step began (progress).
251    ScaffoldStarted {
252        /// The step (`git_init`, `scaffold.1`, …).
253        step: String,
254        /// Its command.
255        argv: Vec<String>,
256        /// `local` or `network`.
257        access: String,
258    },
259    /// A step ended (progress).
260    ScaffoldFinished {
261        /// The step.
262        step: String,
263        /// `done`, `skipped`, `failed`.
264        state: String,
265        /// Its exit status, when it ran.
266        exit: Option<i32>,
267        /// How long it took.
268        duration_ms: u64,
269    },
270    /// The project was created and committed (domain).
271    Created {
272        /// SHA-256 of the folder's canonical path.
273        path_digest: String,
274        /// `new_folder` or `existing_repository`.
275        mode: String,
276        /// The stack.
277        stack: String,
278        /// The spec format.
279        format: String,
280        /// speccoder's commit.
281        commit: String,
282        /// Through what.
283        via: String,
284    },
285    /// The project speccoder created was undone (domain).
286    Undone {
287        /// SHA-256 of the folder's canonical path.
288        path_digest: String,
289        /// `trash` or `revert`.
290        how: String,
291        /// Through what.
292        via: String,
293    },
294}
295
296/// One judged file of [`Event::CollectionChecked`].
297#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
298pub struct CollectionFile {
299    /// Workspace-relative path.
300    pub path: String,
301    /// `collected`, `not_collected`, `unknown`.
302    pub state: speccoder_core::collection::FileState,
303    /// `static`, `junit`, `listing`; `null` when unknown.
304    pub decided_by: Option<speccoder_core::collection::DecidedBy>,
305}
306
307/// A step of the oracle at review.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309#[serde(tag = "kind", rename_all = "snake_case")]
310pub enum OracleEvent {
311    /// Writing tests went one stage further (progress).
312    Stage {
313        /// The requirement.
314        requirement: String,
315        /// `writing`, `comparing`, `checking_red`, `held_out`.
316        stage: String,
317        /// This stage's step.
318        n: u32,
319        /// Of.
320        of: u32,
321    },
322    /// The approval froze a requirement's tests (domain).
323    Frozen {
324        /// The unit.
325        unit: String,
326        /// The requirement.
327        requirement: String,
328        /// The visible set's manifest digest.
329        manifest_sha256: String,
330        /// The approval's digest.
331        approval_sha256: String,
332        /// Visible tests frozen.
333        visible_count: usize,
334        /// Held-out tests sealed with them.
335        held_out_count: usize,
336        /// Who.
337        by: String,
338        /// Through what.
339        via: String,
340    },
341    /// A frozen oracle lapsed with its requirement's wording (domain).
342    Lapsed {
343        /// The unit.
344        unit: String,
345        /// The requirement.
346        requirement: String,
347        /// The wording it was frozen on.
348        frozen_wording_sha256: String,
349        /// The wording now.
350        current_wording_sha256: String,
351    },
352}
353
354/// One statement's class in a reproduce try.
355#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
356pub struct StatementClass {
357    /// `E1`, `U1`.
358    pub statement: String,
359    /// The red class (`assertion`, `passed`, …).
360    pub class: String,
361}
362
363/// A reproduction as the stream shows it: never its content.
364#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
365pub struct ShownReproduction {
366    /// Its statement.
367    pub statement: String,
368    /// Its file.
369    pub file: String,
370    /// Its test.
371    pub test: String,
372    /// Its content's digest.
373    pub digest: String,
374}
375
376/// A special case as the stream shows it.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
378pub struct ShownSpecialCase {
379    /// The statement it targets.
380    pub statement: String,
381    /// Rejected by the combined set.
382    pub rejected: bool,
383}
384
385/// A bug's step.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
387#[serde(tag = "kind", rename_all = "snake_case")]
388pub enum BugEvent {
389    /// The triad is recorded (domain).
390    Recorded {
391        /// The home's kind.
392        home: String,
393        /// The unit.
394        unit: String,
395        /// The requirement.
396        requirement: String,
397        /// The triad's digest.
398        triad_digest: String,
399        /// Through what.
400        via: String,
401    },
402    /// One reproduce try ended.
403    ReproduceTry {
404        /// The try.
405        n: u32,
406        /// Of at most.
407        of: u32,
408        /// Per statement.
409        per_statement: Vec<StatementClass>,
410    },
411    /// Every statement is reproduced.
412    Reproduced {
413        /// The reproductions.
414        reproductions: Vec<ShownReproduction>,
415    },
416    /// The reproductions wait for the person.
417    ConfirmWaiting {
418        /// The digest to confirm.
419        digest: String,
420        /// The command.
421        next: String,
422    },
423    /// The person confirmed them (domain).
424    Confirmed {
425        /// The digest.
426        digest: String,
427        /// Who.
428        by: String,
429        /// Through what.
430        via: String,
431        /// `shown` or `named_test`.
432        basis: String,
433    },
434    /// The person rejected them (domain).
435    ConfirmRejected {
436        /// The digest.
437        digest: String,
438        /// Why.
439        reason: String,
440    },
441    /// The pass-to-pass set was chosen.
442    UnchangedSelected {
443        /// `selected` or `none_selected`.
444        state: String,
445        /// Members.
446        count: usize,
447    },
448    /// The revert and special cases were judged.
449    Discrimination {
450        /// `rejected`, `survived`, `unviable`.
451        revert: String,
452        /// The special cases.
453        special_case: Vec<ShownSpecialCase>,
454    },
455    /// The bug's outcome (domain).
456    Outcome {
457        /// `awaiting_confirmation`, `cannot_reproduce`, …
458        outcome: String,
459        /// The fix's verdict.
460        verdict: Option<String>,
461        /// The stop's code.
462        code: Option<String>,
463        /// What to do next.
464        next: Vec<String>,
465    },
466}
467
468impl BugEvent {
469    /// Its name on the event stream (registered in
470    /// `speccoder_core::event::REGISTRY`).
471    #[must_use]
472    pub const fn name(&self) -> &'static str {
473        match self {
474            Self::Recorded { .. } => "bug.recorded",
475            Self::ReproduceTry { .. } => "bug.reproduce_try",
476            Self::Reproduced { .. } => "bug.reproduced",
477            Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
478            Self::Confirmed { .. } => "bug.confirmed",
479            Self::ConfirmRejected { .. } => "bug.confirm_rejected",
480            Self::UnchangedSelected { .. } => "bug.unchanged_selected",
481            Self::Discrimination { .. } => "bug.discrimination",
482            Self::Outcome { .. } => "bug.outcome",
483        }
484    }
485
486    /// The line a plain progress printer shows.
487    #[must_use]
488    pub fn line(&self) -> String {
489        match self {
490            Self::Recorded {
491                unit, requirement, ..
492            } => format!("bug recorded as {requirement} in {unit}"),
493            Self::ReproduceTry {
494                n,
495                of,
496                per_statement,
497            } => format!(
498                "reproduce try {n}/{of}: {}",
499                per_statement
500                    .iter()
501                    .map(|s| format!("{} {}", s.statement, s.class))
502                    .collect::<Vec<_>>()
503                    .join(", ")
504            ),
505            Self::Reproduced { reproductions } => format!(
506                "reproduced: {} test(s) fail on an assertion",
507                reproductions
508                    .iter()
509                    .filter(|r| r.statement.starts_with('E'))
510                    .count()
511            ),
512            Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
513            Self::Confirmed { by, basis, .. } => {
514                format!("reproductions confirmed by {by} ({basis})")
515            }
516            Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
517            Self::UnchangedSelected { state, count } => {
518                format!("pass-to-pass: {count} test(s) ({state})")
519            }
520            Self::Discrimination {
521                revert,
522                special_case,
523            } => format!(
524                "revert {revert}; {} special case(s), {} rejected",
525                special_case.len(),
526                special_case.iter().filter(|s| s.rejected).count()
527            ),
528            Self::Outcome {
529                outcome, verdict, ..
530            } => match verdict {
531                Some(v) => format!("bug {outcome}: {v}"),
532                None => format!("bug {outcome}"),
533            },
534        }
535    }
536}
537
538/// Receives events as they happen.
539pub trait Progress: Send + Sync + std::fmt::Debug {
540    /// One event.
541    fn event(&self, event: &Event);
542}
543
544/// What `live.json` holds.
545#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
546pub struct LiveState {
547    /// The speccoder process.
548    pub pid: u32,
549    /// Feature directory name.
550    pub feature: String,
551    /// Task ID.
552    pub task: String,
553    /// Task title.
554    pub title: String,
555    /// The current stage.
556    #[serde(default)]
557    pub stage: Option<Stage>,
558    /// Its detail (`1/2`, a commit id).
559    #[serde(default)]
560    pub stage_detail: Option<String>,
561    /// The last tool call, e.g. `edit_file src/lib.rs (allow)`.
562    pub last_call: Option<String>,
563    /// What the model last said, its first line (a claim).
564    #[serde(default)]
565    pub said: Option<String>,
566    /// The last line of the model's latest reasoning summary (a claim).
567    #[serde(default)]
568    pub thinking: Option<String>,
569    /// The latest tool calls, oldest first (at most [`RECENT`]).
570    #[serde(default)]
571    pub recent: Vec<String>,
572    /// Tokens of the current attempt.
573    pub input_tokens: u64,
574    /// Output tokens of the current attempt.
575    pub output_tokens: u64,
576    /// USD of the current attempt, when priced.
577    pub usd: Option<f64>,
578    /// Set when the task run ended: its outcome and message.
579    pub finished: Option<String>,
580    /// Unix seconds when the task run started.
581    pub started_at: u64,
582    /// Unix seconds of the last event.
583    pub updated_at: u64,
584}
585
586fn now() -> u64 {
587    std::time::SystemTime::now()
588        .duration_since(std::time::UNIX_EPOCH)
589        .map_or(0, |d| d.as_secs())
590}
591
592/// USD for token counts (input includes cache reads and writes).
593#[must_use]
594pub fn usd(
595    prices: Option<Prices>,
596    input: u64,
597    output: u64,
598    cache_read: u64,
599    cache_write: u64,
600) -> Option<f64> {
601    prices.map(|p| p.usd(input, output, cache_read, cache_write))
602}
603
604/// Keeps `live.json`, prices usage, forwards to a printer.
605#[derive(Debug)]
606pub struct Live {
607    file: PathBuf,
608    prices: Option<Prices>,
609    sink: Option<Arc<dyn Progress>>,
610    state: Mutex<LiveState>,
611}
612
613impl Live {
614    /// A live record in `dir` forwarding to `sink`.
615    #[must_use]
616    pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
617        let _ = std::fs::create_dir_all(dir);
618        Self {
619            file: dir.join(FILE),
620            prices,
621            sink,
622            state: Mutex::new(LiveState {
623                pid: std::process::id(),
624                ..LiveState::default()
625            }),
626        }
627    }
628
629    fn write(&self, state: &LiveState) {
630        if let Ok(text) = serde_json::to_string_pretty(state) {
631            let tmp = self.file.with_extension("json.tmp");
632            if std::fs::write(&tmp, text).is_ok() {
633                let _ = std::fs::rename(&tmp, &self.file);
634            }
635        }
636    }
637}
638
639impl Progress for Live {
640    fn event(&self, event: &Event) {
641        let mut event = event.clone();
642        // Price what can be priced.
643        match &mut event {
644            Event::Usage {
645                input_tokens,
646                output_tokens,
647                cache_read_tokens,
648                cache_write_tokens,
649                usd: price,
650                ..
651            } => {
652                *price = usd(
653                    self.prices,
654                    *input_tokens,
655                    *output_tokens,
656                    *cache_read_tokens,
657                    *cache_write_tokens,
658                )
659            }
660            Event::TaskFinished {
661                input_tokens,
662                output_tokens,
663                cache_read_tokens,
664                cache_write_tokens,
665                usd: price,
666                ..
667            } => {
668                *price = usd(
669                    self.prices,
670                    *input_tokens,
671                    *output_tokens,
672                    *cache_read_tokens,
673                    *cache_write_tokens,
674                )
675            }
676            _ => {}
677        }
678        if let Ok(mut s) = self.state.lock() {
679            match &event {
680                Event::TaskStarted {
681                    feature,
682                    task,
683                    title,
684                } => {
685                    *s = LiveState {
686                        pid: s.pid,
687                        feature: feature.clone(),
688                        task: task.clone(),
689                        title: title.clone(),
690                        started_at: now(),
691                        ..LiveState::default()
692                    };
693                }
694                Event::Stage {
695                    stage,
696                    state: StageState::Started,
697                    detail,
698                    ..
699                } => {
700                    s.stage = Some(*stage);
701                    s.stage_detail.clone_from(detail);
702                }
703                Event::Said { text, .. } => {
704                    s.said = text
705                        .lines()
706                        .find(|l| !l.trim().is_empty())
707                        .map(|l| l.chars().take(160).collect());
708                }
709                // Its conclusion: where the summary ends up.
710                Event::Reasoning { text, .. } => {
711                    s.thinking = text
712                        .lines()
713                        .rev()
714                        .find(|l| !l.trim().is_empty())
715                        .map(|l| l.chars().take(160).collect());
716                }
717                Event::Stage { .. }
718                | Event::Resumed { .. }
719                | Event::ApprovalRequested { .. }
720                | Event::ApprovalDecided { .. } => {}
721                Event::ToolCall {
722                    turn,
723                    tool,
724                    target,
725                    decision,
726                    ..
727                } => {
728                    let call = format!("{tool} {target} ({decision})");
729                    s.recent.push(format!("turn {turn}  {call}"));
730                    if s.recent.len() > RECENT {
731                        s.recent.remove(0);
732                    }
733                    s.last_call = Some(call);
734                }
735                Event::Usage {
736                    input_tokens,
737                    output_tokens,
738                    usd,
739                    ..
740                } => {
741                    s.input_tokens = *input_tokens;
742                    s.output_tokens = *output_tokens;
743                    s.usd = *usd;
744                }
745                Event::TaskFinished {
746                    outcome, message, ..
747                } => s.finished = Some(format!("{outcome}: {message}")),
748                Event::Notice { .. }
749                | Event::RunStarted { .. }
750                | Event::Bug { .. }
751                | Event::Oracle { .. }
752                | Event::CollectionChecked { .. }
753                | Event::RegressionBisected { .. }
754                | Event::PlanningStopped { .. }
755                | Event::Project { .. } => {}
756            }
757            s.updated_at = now();
758            self.write(&s);
759        }
760        if let Some(sink) = &self.sink {
761            sink.event(&event);
762        }
763    }
764}
765
766/// The target of a tool call, for one line.
767#[must_use]
768pub fn target(tool: &str, args: &serde_json::Value) -> String {
769    let text = match tool {
770        "run_command" => args["argv"]
771            .as_array()
772            .map(|a| {
773                a.iter()
774                    .filter_map(|v| v.as_str())
775                    .collect::<Vec<_>>()
776                    .join(" ")
777            })
778            .unwrap_or_default(),
779        "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
780        // An MCP tool: its first string argument, whatever it is called.
781        name if name.starts_with(crate::mcp::PREFIX) => args
782            .as_object()
783            .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
784            .unwrap_or_default(),
785        _ => args["path"].as_str().unwrap_or_default().to_owned(),
786    };
787    let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
788    if one.chars().count() > 60 {
789        format!("{}…", one.chars().take(59).collect::<String>())
790    } else {
791        one
792    }
793}
794
795// ── `speccoder report --live` ──────────────────────────────────────────────────────
796
797/// The project's `live.json` and whether its process is still alive.
798#[must_use]
799pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
800    let root = match crate::inspect(path, None).data() {
801        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
802        _ => return None,
803    };
804    let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
805        .ok()?;
806    let live: LiveState = serde_json::from_str(&text).ok()?;
807    let running = live.finished.is_none() && alive(live.pid);
808    Some((live, running))
809}
810
811/// Whether process `pid` is alive.
812pub fn alive(pid: u32) -> bool {
813    i32::try_from(pid)
814        .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
815}
816
817/// `speccoder report --live`: the project's current (or last) run, from
818/// `live.json`, in the state directory `state`.
819pub fn status_with(path: &Path, state: &Path) -> CommandResult {
820    status_at(path, state, agentplane::core::Timestamp::now_utc())
821}
822
823/// [`status_with`] as it reads at `now`: a call whose deadline passed by
824/// then is not listed as waiting.
825#[doc(hidden)]
826pub fn status_at(path: &Path, state: &Path, now: agentplane::core::Timestamp) -> CommandResult {
827    // One inspection for both: what runs, and what waits.
828    let inspected = crate::inspect(path, None);
829    let root = match inspected.data() {
830        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
831        _ => return CommandResult::carried("report", &inspected),
832    };
833    let plane_dir = crate::worktree::project_state_dir(state, &root);
834    let res = status_inner(&plane_dir);
835    let lines = waiting_lines(&plane_dir, now);
836    if lines.is_empty() {
837        return res;
838    }
839    let mut next = res.next().to_vec();
840    next.insert(
841        0,
842        speccoder_core::next::NextStep {
843            command: "speccoder report --approvals".to_owned(),
844            why: "see the waiting calls, then approve or reject them".to_owned(),
845        },
846    );
847    let message = format!("{}\n{}", res.message(), lines.join("\n"));
848    let status = res.status();
849    res.reworded(status, &message).with_next(next)
850}
851
852/// One line per call waiting for a person at `now`: what, since when,
853/// until when. A call past its deadline waits no more — the next writer
854/// denies it as expired — so it is not listed, as in every other listing.
855fn waiting_lines(plane_dir: &Path, now: agentplane::core::Timestamp) -> Vec<String> {
856    // Unreadable is said, never shown as nothing waiting. Read beside the
857    // journal's writer (a run in another process); no journal, nothing
858    // waits — and a read never creates one.
859    let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
860    let rt = match crate::tokio_runtime() {
861        Ok(rt) => rt,
862        Err(e) => return unknown(e),
863    };
864    let plane = match crate::substrate::Plane::open_for_reading(plane_dir) {
865        Ok(Some(p)) => p,
866        Ok(None) => return Vec::new(),
867        Err(e) => return unknown(e.0),
868    };
869    let open = match rt.block_on(plane.open_tasks()) {
870        Ok(open) => open,
871        Err(e) => return unknown(e.0),
872    };
873    open.into_iter()
874        .filter(|t| crate::approvals::still_waits(t.due_at, now))
875        .filter_map(|t| {
876            let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
877            let since = (now - t.created_at).whole_minutes().max(0);
878            let until = t
879                .due_at
880                .map(|d| {
881                    format!(
882                        ", denied as expired in {} h",
883                        (d - now).whole_hours().max(0)
884                    )
885                })
886                .unwrap_or_default();
887            Some(format!(
888                "waiting for a person: {} — {} (for {since} min{until})",
889                request.task,
890                request.covers()
891            ))
892        })
893        .collect()
894}
895
896fn status_inner(plane_dir: &Path) -> CommandResult {
897    let plane_dir = plane_dir.to_path_buf();
898    // An in-place session: the mode, and for an interrupted one both ways out.
899    if let Some(s) = crate::in_place::Session::load(&plane_dir) {
900        let mode = s.mode_line();
901        if !alive(s.pid) {
902            let step = |command: &str, why: &str| speccoder_core::next::NextStep {
903                command: command.to_owned(),
904                why: why.to_owned(),
905            };
906            return CommandResult::new(
907                "status",
908                CommandStatus::Ok,
909                &format!(
910                    "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
911                    s.feature, s.pid, s.branch
912                ),
913            )
914            .with_next(vec![
915                step("speccoder run --in-place", "resume where it stopped"),
916                step(
917                    "speccoder run --in-place --return",
918                    "save what it left and switch back without running",
919                ),
920            ]);
921        }
922        let res = status_live(&plane_dir);
923        let message = format!("{}\n{mode}", res.message());
924        let status = res.status();
925        return res.reworded(status, &message);
926    }
927    status_live(&plane_dir)
928}
929
930/// `status` from `live.json`.
931fn status_live(plane_dir: &Path) -> CommandResult {
932    let file = plane_dir.join(FILE);
933    let live: LiveState = match std::fs::read_to_string(&file)
934        .ok()
935        .and_then(|t| serde_json::from_str(&t).ok())
936    {
937        Some(l) => l,
938        None => {
939            return CommandResult::new(
940                "status",
941                CommandStatus::Ok,
942                "no run has been recorded for this project yet",
943            );
944        }
945    };
946    let ago = now().saturating_sub(live.updated_at);
947    let tokens = format!(
948        "{} in · {} out{}",
949        live.input_tokens,
950        live.output_tokens,
951        live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
952    );
953    use speccoder_core::next::{Outcome, next};
954    let outcome = match (&live.finished, alive(live.pid)) {
955        (None, true) => Some(Outcome::Running),
956        (None, false) => Some(Outcome::Interrupted {
957            task: live.task.clone(),
958        }),
959        (Some(_), _) => None,
960    };
961    let message = match (&live.finished, alive(live.pid)) {
962        (None, true) => format!(
963            "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
964            live.pid,
965            live.feature,
966            live.task,
967            stage_word(&live),
968            now().saturating_sub(live.started_at),
969            live.last_call.as_deref().unwrap_or("-"),
970            live.thinking
971                .as_deref()
972                .map(|s| format!("\nthe model thinks (a claim): {s}"))
973                .unwrap_or_default(),
974            live.said
975                .as_deref()
976                .map(|s| format!("\nthe model says (a claim): “{s}”"))
977                .unwrap_or_default(),
978        ),
979        (None, false) => format!(
980            "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
981            live.feature,
982            live.task,
983            stage_word(&live),
984            live.pid
985        ),
986        (Some(outcome), _) => format!(
987            "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
988            live.feature, live.task
989        ),
990    };
991    CommandResult::new("status", CommandStatus::Ok, &message)
992        .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
993}
994
995/// `verify`, `attempt 1/2`, or `starting`.
996#[must_use]
997pub fn stage_word(live: &LiveState) -> String {
998    match (live.stage, &live.stage_detail) {
999        (Some(st), Some(d)) => format!("{} {d}", st.name()),
1000        (Some(st), None) => st.name().to_owned(),
1001        (None, _) => "starting".to_owned(),
1002    }
1003}
1004
1005/// The last lines of command output worth showing: at most five non-empty
1006/// lines, each at most 200 characters, control characters removed.
1007#[must_use]
1008pub fn tail(text: &str) -> Vec<String> {
1009    let lines: Vec<String> = text
1010        .lines()
1011        .map(|l| {
1012            l.chars()
1013                .filter(|c| !c.is_control())
1014                .take(200)
1015                .collect::<String>()
1016        })
1017        .filter(|l| !l.trim().is_empty())
1018        .collect();
1019    lines[lines.len().saturating_sub(5)..].to_vec()
1020}
1021
1022/// [`status_with`] in the default state directory.
1023#[must_use]
1024pub fn status(path: &Path) -> CommandResult {
1025    match crate::worktree::state_dir() {
1026        Some(state) => status_with(path, &state),
1027        None => CommandResult::new(
1028            "status",
1029            CommandStatus::ConfigError,
1030            "no state directory (set SPECCODER_STATE_DIR or HOME)",
1031        )
1032        .with_code("NO-STATE-DIR"),
1033    }
1034}