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    },
169    /// What the model said in a turn, beside its tool calls: its
170    /// account of what it is doing — a claim, never evidence. Control
171    /// characters escaped; at most 2000 characters.
172    Said {
173        /// Task ID.
174        task: String,
175        /// Model turn (1-based) within the attempt.
176        turn: u32,
177        /// The words.
178        text: String,
179    },
180    /// The model's summary of its reasoning in a turn, as the provider
181    /// gave it (`[model] reasoning_summary`): its account of why — a claim,
182    /// never evidence. Control characters escaped; at most 4000 characters.
183    Reasoning {
184        /// Task ID.
185        task: String,
186        /// Model turn (1-based) within the attempt.
187        turn: u32,
188        /// The summary.
189        text: String,
190    },
191    /// Anything else worth a line (convergence rounds, notices).
192    Notice {
193        /// The line.
194        text: String,
195    },
196    /// A bug's step (`bug.*` on the event stream).
197    Bug {
198        /// The task (`T003`, `bug:<slug>`).
199        task: String,
200        /// What happened.
201        step: BugEvent,
202    },
203    /// The oracle at review (`oracle.*` on the event stream): stage names,
204    /// counts and digests — never a test's content, never held-out material.
205    Oracle {
206        /// What happened.
207        step: OracleEvent,
208    },
209}
210
211/// A step of the oracle at review.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
213#[serde(tag = "kind", rename_all = "snake_case")]
214pub enum OracleEvent {
215    /// Writing tests went one stage further (progress).
216    Stage {
217        /// The requirement.
218        requirement: String,
219        /// `writing`, `comparing`, `checking_red`, `held_out`.
220        stage: String,
221        /// This stage's step.
222        n: u32,
223        /// Of.
224        of: u32,
225    },
226    /// The approval froze a requirement's tests (domain).
227    Frozen {
228        /// The unit.
229        unit: String,
230        /// The requirement.
231        requirement: String,
232        /// The visible set's manifest digest.
233        manifest_sha256: String,
234        /// The approval's digest.
235        approval_sha256: String,
236        /// Visible tests frozen.
237        visible_count: usize,
238        /// Held-out tests sealed with them.
239        held_out_count: usize,
240        /// Who.
241        by: String,
242        /// Through what.
243        via: String,
244    },
245    /// A frozen oracle lapsed with its requirement's wording (domain).
246    Lapsed {
247        /// The unit.
248        unit: String,
249        /// The requirement.
250        requirement: String,
251        /// The wording it was frozen on.
252        frozen_wording_sha256: String,
253        /// The wording now.
254        current_wording_sha256: String,
255    },
256}
257
258/// One statement's class in a reproduce try.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
260pub struct StatementClass {
261    /// `E1`, `U1`.
262    pub statement: String,
263    /// The red class (`assertion`, `passed`, …).
264    pub class: String,
265}
266
267/// A reproduction as the stream shows it: never its content.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
269pub struct ShownReproduction {
270    /// Its statement.
271    pub statement: String,
272    /// Its file.
273    pub file: String,
274    /// Its test.
275    pub test: String,
276    /// Its content's digest.
277    pub digest: String,
278}
279
280/// A special case as the stream shows it.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
282pub struct ShownSpecialCase {
283    /// The statement it targets.
284    pub statement: String,
285    /// Rejected by the combined set.
286    pub rejected: bool,
287}
288
289/// A bug's step.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291#[serde(tag = "kind", rename_all = "snake_case")]
292pub enum BugEvent {
293    /// The triad is recorded (domain).
294    Recorded {
295        /// The home's kind.
296        home: String,
297        /// The unit.
298        unit: String,
299        /// The requirement.
300        requirement: String,
301        /// The triad's digest.
302        triad_digest: String,
303        /// Through what.
304        via: String,
305    },
306    /// One reproduce try ended.
307    ReproduceTry {
308        /// The try.
309        n: u32,
310        /// Of at most.
311        of: u32,
312        /// Per statement.
313        per_statement: Vec<StatementClass>,
314    },
315    /// Every statement is reproduced.
316    Reproduced {
317        /// The reproductions.
318        reproductions: Vec<ShownReproduction>,
319    },
320    /// The reproductions wait for the person.
321    ConfirmWaiting {
322        /// The digest to confirm.
323        digest: String,
324        /// The command.
325        next: String,
326    },
327    /// The person confirmed them (domain).
328    Confirmed {
329        /// The digest.
330        digest: String,
331        /// Who.
332        by: String,
333        /// Through what.
334        via: String,
335        /// `shown` or `named_test`.
336        basis: String,
337    },
338    /// The person rejected them (domain).
339    ConfirmRejected {
340        /// The digest.
341        digest: String,
342        /// Why.
343        reason: String,
344    },
345    /// The pass-to-pass set was chosen.
346    UnchangedSelected {
347        /// `selected` or `none_selected`.
348        state: String,
349        /// Members.
350        count: usize,
351    },
352    /// The revert and special cases were judged.
353    Discrimination {
354        /// `rejected`, `survived`, `unviable`.
355        revert: String,
356        /// The special cases.
357        special_case: Vec<ShownSpecialCase>,
358    },
359    /// The bug's outcome (domain).
360    Outcome {
361        /// `awaiting_confirmation`, `cannot_reproduce`, …
362        outcome: String,
363        /// The fix's verdict.
364        verdict: Option<String>,
365        /// The stop's code.
366        code: Option<String>,
367        /// What to do next.
368        next: Vec<String>,
369    },
370}
371
372impl BugEvent {
373    /// Its name on the event stream (registered in
374    /// `speccoder_core::event::REGISTRY`).
375    #[must_use]
376    pub const fn name(&self) -> &'static str {
377        match self {
378            Self::Recorded { .. } => "bug.recorded",
379            Self::ReproduceTry { .. } => "bug.reproduce_try",
380            Self::Reproduced { .. } => "bug.reproduced",
381            Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
382            Self::Confirmed { .. } => "bug.confirmed",
383            Self::ConfirmRejected { .. } => "bug.confirm_rejected",
384            Self::UnchangedSelected { .. } => "bug.unchanged_selected",
385            Self::Discrimination { .. } => "bug.discrimination",
386            Self::Outcome { .. } => "bug.outcome",
387        }
388    }
389
390    /// The line a plain progress printer shows.
391    #[must_use]
392    pub fn line(&self) -> String {
393        match self {
394            Self::Recorded {
395                unit, requirement, ..
396            } => format!("bug recorded as {requirement} in {unit}"),
397            Self::ReproduceTry {
398                n,
399                of,
400                per_statement,
401            } => format!(
402                "reproduce try {n}/{of}: {}",
403                per_statement
404                    .iter()
405                    .map(|s| format!("{} {}", s.statement, s.class))
406                    .collect::<Vec<_>>()
407                    .join(", ")
408            ),
409            Self::Reproduced { reproductions } => format!(
410                "reproduced: {} test(s) fail on an assertion",
411                reproductions
412                    .iter()
413                    .filter(|r| r.statement.starts_with('E'))
414                    .count()
415            ),
416            Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
417            Self::Confirmed { by, basis, .. } => {
418                format!("reproductions confirmed by {by} ({basis})")
419            }
420            Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
421            Self::UnchangedSelected { state, count } => {
422                format!("pass-to-pass: {count} test(s) ({state})")
423            }
424            Self::Discrimination {
425                revert,
426                special_case,
427            } => format!(
428                "revert {revert}; {} special case(s), {} rejected",
429                special_case.len(),
430                special_case.iter().filter(|s| s.rejected).count()
431            ),
432            Self::Outcome {
433                outcome, verdict, ..
434            } => match verdict {
435                Some(v) => format!("bug {outcome}: {v}"),
436                None => format!("bug {outcome}"),
437            },
438        }
439    }
440}
441
442/// Receives events as they happen.
443pub trait Progress: Send + Sync + std::fmt::Debug {
444    /// One event.
445    fn event(&self, event: &Event);
446}
447
448/// What `live.json` holds.
449#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
450pub struct LiveState {
451    /// The speccoder process.
452    pub pid: u32,
453    /// Feature directory name.
454    pub feature: String,
455    /// Task ID.
456    pub task: String,
457    /// Task title.
458    pub title: String,
459    /// The current stage.
460    #[serde(default)]
461    pub stage: Option<Stage>,
462    /// Its detail (`1/2`, a commit id).
463    #[serde(default)]
464    pub stage_detail: Option<String>,
465    /// The last tool call, e.g. `edit_file src/lib.rs (allow)`.
466    pub last_call: Option<String>,
467    /// What the model last said, its first line (a claim).
468    #[serde(default)]
469    pub said: Option<String>,
470    /// The last line of the model's latest reasoning summary (a claim).
471    #[serde(default)]
472    pub thinking: Option<String>,
473    /// The latest tool calls, oldest first (at most [`RECENT`]).
474    #[serde(default)]
475    pub recent: Vec<String>,
476    /// Tokens of the current attempt.
477    pub input_tokens: u64,
478    /// Output tokens of the current attempt.
479    pub output_tokens: u64,
480    /// USD of the current attempt, when priced.
481    pub usd: Option<f64>,
482    /// Set when the task run ended: its outcome and message.
483    pub finished: Option<String>,
484    /// Unix seconds when the task run started.
485    pub started_at: u64,
486    /// Unix seconds of the last event.
487    pub updated_at: u64,
488}
489
490fn now() -> u64 {
491    std::time::SystemTime::now()
492        .duration_since(std::time::UNIX_EPOCH)
493        .map_or(0, |d| d.as_secs())
494}
495
496/// USD for token counts (input includes cache reads and writes).
497#[must_use]
498pub fn usd(
499    prices: Option<Prices>,
500    input: u64,
501    output: u64,
502    cache_read: u64,
503    cache_write: u64,
504) -> Option<f64> {
505    prices.map(|p| p.usd(input, output, cache_read, cache_write))
506}
507
508/// Keeps `live.json`, prices usage, forwards to a printer.
509#[derive(Debug)]
510pub struct Live {
511    file: PathBuf,
512    prices: Option<Prices>,
513    sink: Option<Arc<dyn Progress>>,
514    state: Mutex<LiveState>,
515}
516
517impl Live {
518    /// A live record in `dir` forwarding to `sink`.
519    #[must_use]
520    pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
521        let _ = std::fs::create_dir_all(dir);
522        Self {
523            file: dir.join(FILE),
524            prices,
525            sink,
526            state: Mutex::new(LiveState {
527                pid: std::process::id(),
528                ..LiveState::default()
529            }),
530        }
531    }
532
533    fn write(&self, state: &LiveState) {
534        if let Ok(text) = serde_json::to_string_pretty(state) {
535            let tmp = self.file.with_extension("json.tmp");
536            if std::fs::write(&tmp, text).is_ok() {
537                let _ = std::fs::rename(&tmp, &self.file);
538            }
539        }
540    }
541}
542
543impl Progress for Live {
544    fn event(&self, event: &Event) {
545        let mut event = event.clone();
546        // Price what can be priced.
547        match &mut event {
548            Event::Usage {
549                input_tokens,
550                output_tokens,
551                cache_read_tokens,
552                cache_write_tokens,
553                usd: price,
554                ..
555            } => {
556                *price = usd(
557                    self.prices,
558                    *input_tokens,
559                    *output_tokens,
560                    *cache_read_tokens,
561                    *cache_write_tokens,
562                )
563            }
564            Event::TaskFinished {
565                input_tokens,
566                output_tokens,
567                cache_read_tokens,
568                cache_write_tokens,
569                usd: price,
570                ..
571            } => {
572                *price = usd(
573                    self.prices,
574                    *input_tokens,
575                    *output_tokens,
576                    *cache_read_tokens,
577                    *cache_write_tokens,
578                )
579            }
580            _ => {}
581        }
582        if let Ok(mut s) = self.state.lock() {
583            match &event {
584                Event::TaskStarted {
585                    feature,
586                    task,
587                    title,
588                } => {
589                    *s = LiveState {
590                        pid: s.pid,
591                        feature: feature.clone(),
592                        task: task.clone(),
593                        title: title.clone(),
594                        started_at: now(),
595                        ..LiveState::default()
596                    };
597                }
598                Event::Stage {
599                    stage,
600                    state: StageState::Started,
601                    detail,
602                    ..
603                } => {
604                    s.stage = Some(*stage);
605                    s.stage_detail.clone_from(detail);
606                }
607                Event::Said { text, .. } => {
608                    s.said = text
609                        .lines()
610                        .find(|l| !l.trim().is_empty())
611                        .map(|l| l.chars().take(160).collect());
612                }
613                // Its conclusion: where the summary ends up.
614                Event::Reasoning { text, .. } => {
615                    s.thinking = text
616                        .lines()
617                        .rev()
618                        .find(|l| !l.trim().is_empty())
619                        .map(|l| l.chars().take(160).collect());
620                }
621                Event::Stage { .. }
622                | Event::Resumed { .. }
623                | Event::ApprovalRequested { .. }
624                | Event::ApprovalDecided { .. } => {}
625                Event::ToolCall {
626                    turn,
627                    tool,
628                    target,
629                    decision,
630                    ..
631                } => {
632                    let call = format!("{tool} {target} ({decision})");
633                    s.recent.push(format!("turn {turn}  {call}"));
634                    if s.recent.len() > RECENT {
635                        s.recent.remove(0);
636                    }
637                    s.last_call = Some(call);
638                }
639                Event::Usage {
640                    input_tokens,
641                    output_tokens,
642                    usd,
643                    ..
644                } => {
645                    s.input_tokens = *input_tokens;
646                    s.output_tokens = *output_tokens;
647                    s.usd = *usd;
648                }
649                Event::TaskFinished {
650                    outcome, message, ..
651                } => s.finished = Some(format!("{outcome}: {message}")),
652                Event::Notice { .. }
653                | Event::RunStarted { .. }
654                | Event::Bug { .. }
655                | Event::Oracle { .. } => {}
656            }
657            s.updated_at = now();
658            self.write(&s);
659        }
660        if let Some(sink) = &self.sink {
661            sink.event(&event);
662        }
663    }
664}
665
666/// The target of a tool call, for one line.
667#[must_use]
668pub fn target(tool: &str, args: &serde_json::Value) -> String {
669    let text = match tool {
670        "run_command" => args["argv"]
671            .as_array()
672            .map(|a| {
673                a.iter()
674                    .filter_map(|v| v.as_str())
675                    .collect::<Vec<_>>()
676                    .join(" ")
677            })
678            .unwrap_or_default(),
679        "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
680        // An MCP tool: its first string argument, whatever it is called.
681        name if name.starts_with(crate::mcp::PREFIX) => args
682            .as_object()
683            .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
684            .unwrap_or_default(),
685        _ => args["path"].as_str().unwrap_or_default().to_owned(),
686    };
687    let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
688    if one.chars().count() > 60 {
689        format!("{}…", one.chars().take(59).collect::<String>())
690    } else {
691        one
692    }
693}
694
695// ── `speccoder report --live` ──────────────────────────────────────────────────────
696
697/// The project's `live.json` and whether its process is still alive.
698#[must_use]
699pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
700    let root = match crate::inspect(path, None).data() {
701        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
702        _ => return None,
703    };
704    let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
705        .ok()?;
706    let live: LiveState = serde_json::from_str(&text).ok()?;
707    let running = live.finished.is_none() && alive(live.pid);
708    Some((live, running))
709}
710
711/// Whether process `pid` is alive.
712pub fn alive(pid: u32) -> bool {
713    i32::try_from(pid)
714        .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
715}
716
717/// `speccoder report --live`: the project's current (or last) run, from
718/// `live.json`, in the state directory `state`.
719pub fn status_with(path: &Path, state: &Path) -> CommandResult {
720    // One inspection for both: what runs, and what waits.
721    let inspected = crate::inspect(path, None);
722    let root = match inspected.data() {
723        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
724        _ => return CommandResult::carried("report", &inspected),
725    };
726    let plane_dir = crate::worktree::project_state_dir(state, &root);
727    let res = status_inner(&plane_dir);
728    let lines = waiting_lines(&plane_dir);
729    if lines.is_empty() {
730        return res;
731    }
732    let mut next = res.next().to_vec();
733    next.insert(
734        0,
735        speccoder_core::next::NextStep {
736            command: "speccoder report --approvals".to_owned(),
737            why: "see the waiting calls, then approve or reject them".to_owned(),
738        },
739    );
740    let message = format!("{}\n{}", res.message(), lines.join("\n"));
741    let status = res.status();
742    res.reworded(status, &message).with_next(next)
743}
744
745/// One line per call waiting for a person: what, since when, until when.
746fn waiting_lines(plane_dir: &Path) -> Vec<String> {
747    // No journal, nothing waits — and a read never creates one.
748    if !plane_dir.join(crate::substrate::JOURNAL).exists() {
749        return Vec::new();
750    }
751    // Unreadable is said, never shown as nothing waiting.
752    let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
753    let rt = match crate::tokio_runtime() {
754        Ok(rt) => rt,
755        Err(e) => return unknown(e),
756    };
757    let plane = match crate::substrate::Plane::open_for_decisions(plane_dir) {
758        Ok(p) => p,
759        Err(e) => return unknown(e.0),
760    };
761    let now = agentplane::core::Timestamp::now_utc();
762    let open = match rt.block_on(plane.open_tasks()) {
763        Ok(open) => open,
764        Err(e) => return unknown(e.0),
765    };
766    open.into_iter()
767        .filter_map(|t| {
768            let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
769            let since = (now - t.created_at).whole_minutes().max(0);
770            let until = t
771                .due_at
772                .map(|d| {
773                    format!(
774                        ", denied as expired in {} h",
775                        (d - now).whole_hours().max(0)
776                    )
777                })
778                .unwrap_or_default();
779            Some(format!(
780                "waiting for a person: {} — {} (for {since} min{until})",
781                request.task,
782                request.covers()
783            ))
784        })
785        .collect()
786}
787
788fn status_inner(plane_dir: &Path) -> CommandResult {
789    let plane_dir = plane_dir.to_path_buf();
790    // An in-place session: the mode, and for an interrupted one both ways out.
791    if let Some(s) = crate::in_place::Session::load(&plane_dir) {
792        let mode = s.mode_line();
793        if !alive(s.pid) {
794            let step = |command: &str, why: &str| speccoder_core::next::NextStep {
795                command: command.to_owned(),
796                why: why.to_owned(),
797            };
798            return CommandResult::new(
799                "status",
800                CommandStatus::Ok,
801                &format!(
802                    "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
803                    s.feature, s.pid, s.branch
804                ),
805            )
806            .with_next(vec![
807                step("speccoder run --in-place", "resume where it stopped"),
808                step(
809                    "speccoder run --in-place --return",
810                    "save what it left and switch back without running",
811                ),
812            ]);
813        }
814        let res = status_live(&plane_dir);
815        let message = format!("{}\n{mode}", res.message());
816        let status = res.status();
817        return res.reworded(status, &message);
818    }
819    status_live(&plane_dir)
820}
821
822/// `status` from `live.json`.
823fn status_live(plane_dir: &Path) -> CommandResult {
824    let file = plane_dir.join(FILE);
825    let live: LiveState = match std::fs::read_to_string(&file)
826        .ok()
827        .and_then(|t| serde_json::from_str(&t).ok())
828    {
829        Some(l) => l,
830        None => {
831            return CommandResult::new(
832                "status",
833                CommandStatus::Ok,
834                "no run has been recorded for this project yet",
835            );
836        }
837    };
838    let ago = now().saturating_sub(live.updated_at);
839    let tokens = format!(
840        "{} in · {} out{}",
841        live.input_tokens,
842        live.output_tokens,
843        live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
844    );
845    use speccoder_core::next::{Outcome, next};
846    let outcome = match (&live.finished, alive(live.pid)) {
847        (None, true) => Some(Outcome::Running),
848        (None, false) => Some(Outcome::Interrupted {
849            task: live.task.clone(),
850        }),
851        (Some(_), _) => None,
852    };
853    let message = match (&live.finished, alive(live.pid)) {
854        (None, true) => format!(
855            "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
856            live.pid,
857            live.feature,
858            live.task,
859            stage_word(&live),
860            now().saturating_sub(live.started_at),
861            live.last_call.as_deref().unwrap_or("-"),
862            live.thinking
863                .as_deref()
864                .map(|s| format!("\nthe model thinks (a claim): {s}"))
865                .unwrap_or_default(),
866            live.said
867                .as_deref()
868                .map(|s| format!("\nthe model says (a claim): “{s}”"))
869                .unwrap_or_default(),
870        ),
871        (None, false) => format!(
872            "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
873            live.feature,
874            live.task,
875            stage_word(&live),
876            live.pid
877        ),
878        (Some(outcome), _) => format!(
879            "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
880            live.feature, live.task
881        ),
882    };
883    CommandResult::new("status", CommandStatus::Ok, &message)
884        .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
885}
886
887/// `verify`, `attempt 1/2`, or `starting`.
888#[must_use]
889pub fn stage_word(live: &LiveState) -> String {
890    match (live.stage, &live.stage_detail) {
891        (Some(st), Some(d)) => format!("{} {d}", st.name()),
892        (Some(st), None) => st.name().to_owned(),
893        (None, _) => "starting".to_owned(),
894    }
895}
896
897/// The last lines of command output worth showing: at most five non-empty
898/// lines, each at most 200 characters, control characters removed.
899#[must_use]
900pub fn tail(text: &str) -> Vec<String> {
901    let lines: Vec<String> = text
902        .lines()
903        .map(|l| {
904            l.chars()
905                .filter(|c| !c.is_control())
906                .take(200)
907                .collect::<String>()
908        })
909        .filter(|l| !l.trim().is_empty())
910        .collect();
911    lines[lines.len().saturating_sub(5)..].to_vec()
912}
913
914/// [`status_with`] in the default state directory.
915#[must_use]
916pub fn status(path: &Path) -> CommandResult {
917    match crate::worktree::state_dir() {
918        Some(state) => status_with(path, &state),
919        None => CommandResult::new(
920            "status",
921            CommandStatus::ConfigError,
922            "no state directory (set SPECCODER_STATE_DIR or HOME)",
923        )
924        .with_code("NO-STATE-DIR"),
925    }
926}