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/// One entry of an external agent's plan.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct PlanEntry {
35    /// What it says it will do (its words, escaped, at most 200 characters).
36    pub content: String,
37    /// `pending`, `in_progress` or `completed`.
38    pub status: String,
39}
40
41/// Tool calls `live.json` keeps for watchers (`speccoder tui`).
42pub const RECENT: usize = 12;
43
44/// Something a run did.
45#[derive(Debug, Clone, PartialEq, Serialize)]
46#[serde(tag = "event", rename_all = "snake_case")]
47pub enum Event {
48    /// A feature run begins: its tasks, as the graph has them (editors show
49    /// them as a plan).
50    RunStarted {
51        /// Feature directory name.
52        feature: String,
53        /// Every task, in document order.
54        tasks: Vec<PlannedTask>,
55    },
56    /// A task attempt run begins.
57    TaskStarted {
58        /// Feature directory name.
59        feature: String,
60        /// Task ID.
61        task: String,
62        /// Task title.
63        title: String,
64    },
65    /// A stage of the task's pipeline began, ended or does not apply.
66    Stage {
67        /// Task ID.
68        task: String,
69        /// Which stage.
70        stage: Stage,
71        /// Where it is.
72        state: StageState,
73        /// `1/2 · sample 2/3` for an attempt, a short commit id, a reason.
74        detail: Option<String>,
75        /// How long it took, once it ended.
76        elapsed_ms: Option<u64>,
77        /// The last lines of a failed check (at most five).
78        tail: Vec<String>,
79    },
80    /// A run picks up an attempt a killed process left behind.
81    Resumed {
82        /// Task ID.
83        task: String,
84        /// The stage it was in, when known.
85        stage: Option<Stage>,
86        /// The last tool call, when known.
87        last_call: Option<String>,
88    },
89    /// A call waits for a person: notify them.
90    ApprovalRequested {
91        /// Task ID.
92        task: String,
93        /// The model's call id.
94        call: String,
95        /// The tool.
96        tool: String,
97        /// What waits, e.g. `cargo add x` (no control characters, ≤ 60 chars).
98        what: String,
99        /// The class of the object a decision covers: `write`, `command`,
100        /// `network` or `mcp` (empty: none, the call is decided once).
101        class: String,
102        /// The object's short form: the path, `argv#<12 hex>`,
103        /// `<tool>@<12 hex>` — no argv beyond what `what` shows.
104        object: String,
105    },
106    /// A person decided a call inline.
107    ApprovalDecided {
108        /// Task ID.
109        task: String,
110        /// The model's call id.
111        call: String,
112        /// `approve` or `reject`.
113        decision: String,
114        /// `once`, `task` or `run`, for an approval.
115        scope: Option<String>,
116        /// Who decided.
117        by: String,
118        /// How that is known: `asserted`, `connected` (or `expired`).
119        how: String,
120        /// The object's class, as for [`Event::ApprovalRequested`].
121        class: String,
122        /// The object's short form.
123        object: String,
124    },
125    /// A tool call the model asked for, with the policy's decision.
126    ToolCall {
127        /// Task ID.
128        task: String,
129        /// Model turn (1-based) within the attempt.
130        turn: u32,
131        /// The tool.
132        tool: String,
133        /// Its target: a path, a command, a pattern.
134        target: String,
135        /// `allow`, `deny`, `approval`, `unknown`, `stop`.
136        decision: String,
137        /// Whether an allowed call succeeded.
138        ok: Option<bool>,
139        /// The last lines of a failed command (at most five).
140        tail: Vec<String>,
141    },
142    /// Tokens so far in the current attempt.
143    Usage {
144        /// Task ID.
145        task: String,
146        /// Input tokens.
147        input_tokens: u64,
148        /// Output tokens.
149        output_tokens: u64,
150        /// Input tokens read from the cache.
151        cache_read_tokens: u64,
152        /// Input tokens written to the cache.
153        cache_write_tokens: u64,
154        /// USD, when priced.
155        usd: Option<f64>,
156    },
157    /// The task run ended.
158    TaskFinished {
159        /// Task ID.
160        task: String,
161        /// `verified`, `regression_only`, `failed`, `blocked`, …
162        outcome: String,
163        /// The run's message.
164        message: String,
165        /// The attempt's agentplane run, when one ran.
166        run: Option<String>,
167        /// Input tokens, all attempts and the acceptance call.
168        input_tokens: u64,
169        /// Output tokens.
170        output_tokens: u64,
171        /// Input tokens read from the cache.
172        cache_read_tokens: u64,
173        /// Input tokens written to the cache.
174        cache_write_tokens: u64,
175        /// USD, when priced.
176        usd: Option<f64>,
177        /// A regression-only tick: why, worded.
178        regression: Option<speccoder_core::regression::Regression>,
179    },
180    /// The attempt's changed test files were checked against what the run
181    /// collected (`collection.checked`).
182    CollectionChecked {
183        /// Task ID.
184        task: String,
185        /// Each judged file: path, state, what decided it.
186        files: Vec<CollectionFile>,
187    },
188    /// An external agent makes the attempt: its name, and what its
189    /// permission mode is (`decides_alone`: calls it makes without asking
190    /// never reach speccoder, so an empty Needs You proves nothing).
191    AgentStarted {
192        /// Task ID.
193        task: String,
194        /// The agent's name.
195        agent: String,
196        /// Its permission mode, in a sentence.
197        mode: String,
198        /// It is in a mode that decides some calls without asking.
199        decides_alone: bool,
200        /// A recording of its version says whether it asks (`false`: not
201        /// known, and said so).
202        known: bool,
203    },
204    /// An external agent changed something without asking (its own
205    /// settings let it): the count so far.
206    AgentUnasked {
207        /// Task ID.
208        task: String,
209        /// Calls it reported making without a request of speccoder's.
210        calls: u32,
211    },
212    /// An external agent's plan for its attempt, as it last sent it: its
213    /// claim of what it will do, never evidence.
214    AgentPlan {
215        /// Task ID.
216        task: String,
217        /// The entries, in its order.
218        entries: Vec<PlanEntry>,
219    },
220    /// What the model said in a turn, beside its tool calls: its
221    /// account of what it is doing — a claim, never evidence. Control
222    /// characters escaped; at most 2000 characters.
223    Said {
224        /// Task ID.
225        task: String,
226        /// Model turn (1-based) within the attempt.
227        turn: u32,
228        /// The words.
229        text: String,
230    },
231    /// The model's summary of its reasoning in a turn, as the provider
232    /// gave it (`[model] reasoning_summary`): its account of why — a claim,
233    /// never evidence. Control characters escaped; at most 4000 characters.
234    Reasoning {
235        /// Task ID.
236        task: String,
237        /// Model turn (1-based) within the attempt.
238        turn: u32,
239        /// The summary.
240        text: String,
241    },
242    /// Anything else worth a line (convergence rounds, notices).
243    Notice {
244        /// The line.
245        text: String,
246    },
247    /// A bug's step (`bug.*` on the event stream).
248    Bug {
249        /// The task (`T003`, `bug:<slug>`).
250        task: String,
251        /// What happened.
252        step: BugEvent,
253    },
254    /// The oracle at review (`oracle.*` on the event stream): stage names,
255    /// counts and digests — never a test's content, never held-out material.
256    Oracle {
257        /// What happened.
258        step: OracleEvent,
259    },
260    /// Plan through stopped (`planning.stopped`).
261    PlanningStopped {
262        /// The unit, once it exists.
263        unit: Option<String>,
264        /// `questions`, `review`, `failed`, `done`.
265        kind: String,
266        /// The skill a continuation runs next.
267        next_skill: Option<String>,
268    },
269    /// A quick change or bug fix that grew is offered as a spec of its own
270    /// (`ladder.offered`): the signals that hold, by id.
271    LadderOffered {
272        /// Its unit.
273        unit: String,
274        /// The task.
275        task: String,
276        /// `S1`…`S4`.
277        signals: Vec<String>,
278    },
279    /// A unit was promoted from a quick change or bug fix (`unit.promoted`):
280    /// ids and digests, never the words.
281    UnitPromoted {
282        /// The new unit.
283        unit: String,
284        /// Where it came from.
285        from_unit: String,
286        /// The requirement promoted.
287        requirement: String,
288        /// The task it came from.
289        task: String,
290        /// The requirement's words' digest.
291        requirement_sha256: String,
292        /// The digests of the conversations carried.
293        conversation_sha256: Vec<String>,
294        /// The signals that held.
295        signals: Vec<String>,
296        /// Who.
297        by: String,
298        /// Through what.
299        via: String,
300    },
301    /// A new project's step, its creation or its undo (`scaffold.*`,
302    /// `project.*`): step ids, argv, states, digests — never the idea,
303    /// never the folder's path.
304    Project {
305        /// What happened.
306        step: ProjectEvent,
307    },
308    /// An Ask turn read something (`ask.step`).
309    AskStep {
310        /// The conversation.
311        conversation: String,
312        /// The turn.
313        turn: u32,
314        /// The tool.
315        tool: String,
316        /// What it read, in one line.
317        target: String,
318        /// The read id the answer may cite, when it returned something.
319        read: Option<String>,
320        /// It failed or was refused.
321        refused: bool,
322    },
323    /// An Ask turn's answer as it is written (`ask.text`).
324    AskText {
325        /// The conversation.
326        conversation: String,
327        /// The turn.
328        turn: u32,
329        /// The next words.
330        delta: String,
331    },
332    /// Inside the Ask plane: a turn's raw end, which `ask::turn` turns
333    /// into [`Event::AskDone`]; never forwarded.
334    AskTurnEnded {
335        /// `ask:<conversation>#<turn>`.
336        tag: String,
337        /// The loop's output.
338        output: Box<serde_json::Value>,
339    },
340    /// An Ask turn ended (`ask.done`).
341    AskDone {
342        /// The turn as shown.
343        report: Box<speccoder_core::ask::AskReport>,
344    },
345}
346
347/// A new project's step, its creation or its undo.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
349#[serde(tag = "event", rename_all = "snake_case")]
350pub enum ProjectEvent {
351    /// A step began (progress).
352    ScaffoldStarted {
353        /// The step (`git_init`, `scaffold.1`, …).
354        step: String,
355        /// Its command.
356        argv: Vec<String>,
357        /// `local` or `network`.
358        access: String,
359    },
360    /// A step ended (progress).
361    ScaffoldFinished {
362        /// The step.
363        step: String,
364        /// `done`, `skipped`, `failed`.
365        state: String,
366        /// Its exit status, when it ran.
367        exit: Option<i32>,
368        /// How long it took.
369        duration_ms: u64,
370    },
371    /// The project was created and committed (domain).
372    Created {
373        /// SHA-256 of the folder's canonical path.
374        path_digest: String,
375        /// `new_folder` or `existing_repository`.
376        mode: String,
377        /// The stack.
378        stack: String,
379        /// The spec format.
380        format: String,
381        /// speccoder's commit.
382        commit: String,
383        /// Through what.
384        via: String,
385    },
386    /// The project speccoder created was undone (domain).
387    Undone {
388        /// SHA-256 of the folder's canonical path.
389        path_digest: String,
390        /// `trash` or `revert`.
391        how: String,
392        /// Through what.
393        via: String,
394    },
395}
396
397/// One judged file of [`Event::CollectionChecked`].
398#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
399pub struct CollectionFile {
400    /// Workspace-relative path.
401    pub path: String,
402    /// `collected`, `not_collected`, `unknown`.
403    pub state: speccoder_core::collection::FileState,
404    /// `static`, `junit`, `listing`; `null` when unknown.
405    pub decided_by: Option<speccoder_core::collection::DecidedBy>,
406}
407
408/// A step of the oracle at review.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
410#[serde(tag = "kind", rename_all = "snake_case")]
411pub enum OracleEvent {
412    /// Writing tests went one stage further (progress).
413    Stage {
414        /// The requirement.
415        requirement: String,
416        /// `writing`, `comparing`, `checking_red`, `held_out`.
417        stage: String,
418        /// This stage's step.
419        n: u32,
420        /// Of.
421        of: u32,
422    },
423    /// The approval froze a requirement's tests (domain).
424    Frozen {
425        /// The unit.
426        unit: String,
427        /// The requirement.
428        requirement: String,
429        /// The visible set's manifest digest.
430        manifest_sha256: String,
431        /// The approval's digest.
432        approval_sha256: String,
433        /// Visible tests frozen.
434        visible_count: usize,
435        /// Held-out tests sealed with them.
436        held_out_count: usize,
437        /// Who.
438        by: String,
439        /// Through what.
440        via: String,
441    },
442    /// A frozen oracle lapsed with its requirement's wording (domain).
443    Lapsed {
444        /// The unit.
445        unit: String,
446        /// The requirement.
447        requirement: String,
448        /// The wording it was frozen on.
449        frozen_wording_sha256: String,
450        /// The wording now.
451        current_wording_sha256: String,
452    },
453}
454
455/// One statement's class in a reproduce try.
456#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
457pub struct StatementClass {
458    /// `E1`, `U1`.
459    pub statement: String,
460    /// The red class (`assertion`, `passed`, …).
461    pub class: String,
462}
463
464/// A reproduction as the stream shows it: never its content.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
466pub struct ShownReproduction {
467    /// Its statement.
468    pub statement: String,
469    /// Its file.
470    pub file: String,
471    /// Its test.
472    pub test: String,
473    /// Its content's digest.
474    pub digest: String,
475}
476
477/// A special case as the stream shows it.
478#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
479pub struct ShownSpecialCase {
480    /// The statement it targets.
481    pub statement: String,
482    /// Rejected by the combined set.
483    pub rejected: bool,
484}
485
486/// A bug's step.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488#[serde(tag = "kind", rename_all = "snake_case")]
489pub enum BugEvent {
490    /// The triad is recorded (domain).
491    Recorded {
492        /// The home's kind.
493        home: String,
494        /// The unit.
495        unit: String,
496        /// The requirement.
497        requirement: String,
498        /// The triad's digest.
499        triad_digest: String,
500        /// Through what.
501        via: String,
502    },
503    /// One reproduce try ended.
504    ReproduceTry {
505        /// The try.
506        n: u32,
507        /// Of at most.
508        of: u32,
509        /// Per statement.
510        per_statement: Vec<StatementClass>,
511    },
512    /// Every statement is reproduced.
513    Reproduced {
514        /// The reproductions.
515        reproductions: Vec<ShownReproduction>,
516    },
517    /// The reproductions wait for the person.
518    ConfirmWaiting {
519        /// The digest to confirm.
520        digest: String,
521        /// The command.
522        next: String,
523    },
524    /// The person confirmed them (domain).
525    Confirmed {
526        /// The digest.
527        digest: String,
528        /// Who.
529        by: String,
530        /// Through what.
531        via: String,
532        /// `shown` or `named_test`.
533        basis: String,
534    },
535    /// The person rejected them (domain).
536    ConfirmRejected {
537        /// The digest.
538        digest: String,
539        /// Why.
540        reason: String,
541    },
542    /// The pass-to-pass set was chosen.
543    UnchangedSelected {
544        /// `selected` or `none_selected`.
545        state: String,
546        /// Members.
547        count: usize,
548    },
549    /// The revert and special cases were judged.
550    Discrimination {
551        /// `rejected`, `survived`, `unviable`.
552        revert: String,
553        /// The special cases.
554        special_case: Vec<ShownSpecialCase>,
555    },
556    /// The bug's outcome (domain).
557    Outcome {
558        /// `awaiting_confirmation`, `cannot_reproduce`, …
559        outcome: String,
560        /// The fix's verdict.
561        verdict: Option<String>,
562        /// The stop's code.
563        code: Option<String>,
564        /// What to do next.
565        next: Vec<String>,
566    },
567}
568
569impl BugEvent {
570    /// Its name on the event stream (registered in
571    /// `speccoder_core::event::REGISTRY`).
572    #[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    /// The line a plain progress printer shows.
588    #[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
639/// Receives events as they happen.
640pub trait Progress: Send + Sync + std::fmt::Debug {
641    /// One event.
642    fn event(&self, event: &Event);
643}
644
645/// What `live.json` holds.
646#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
647pub struct LiveState {
648    /// The speccoder process.
649    pub pid: u32,
650    /// Feature directory name.
651    pub feature: String,
652    /// Task ID.
653    pub task: String,
654    /// Task title.
655    pub title: String,
656    /// The current stage.
657    #[serde(default)]
658    pub stage: Option<Stage>,
659    /// Its detail (`1/2`, a commit id).
660    #[serde(default)]
661    pub stage_detail: Option<String>,
662    /// The last tool call, e.g. `edit_file src/lib.rs (allow)`.
663    pub last_call: Option<String>,
664    /// What the model last said, its first line (a claim).
665    #[serde(default)]
666    pub said: Option<String>,
667    /// The last line of the model's latest reasoning summary (a claim).
668    #[serde(default)]
669    pub thinking: Option<String>,
670    /// The latest tool calls, oldest first (at most [`RECENT`]).
671    #[serde(default)]
672    pub recent: Vec<String>,
673    /// Tokens of the current attempt.
674    pub input_tokens: u64,
675    /// Output tokens of the current attempt.
676    pub output_tokens: u64,
677    /// USD of the current attempt, when priced.
678    pub usd: Option<f64>,
679    /// Set when the task run ended: its outcome and message.
680    pub finished: Option<String>,
681    /// Unix seconds when the task run started.
682    pub started_at: u64,
683    /// Unix seconds of the last event.
684    pub updated_at: u64,
685    /// The external agent making the attempt, when one is.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub agent: Option<LiveAgent>,
688}
689
690/// An external agent's attempt as it goes: its plan, and what speccoder
691/// decided of what it asked.
692#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
693pub struct LiveAgent {
694    /// Its name.
695    pub name: String,
696    /// Its permission mode, in a sentence.
697    pub mode: String,
698    /// It decides some calls without asking (its mode, or calls it was
699    /// seen to make unasked).
700    pub decides_alone: bool,
701    /// A recording says whether it asks.
702    #[serde(default)]
703    pub known: bool,
704    /// Calls it made without asking.
705    #[serde(default)]
706    pub unasked: u32,
707    /// Its plan, as it last sent it.
708    #[serde(default)]
709    pub plan: Vec<PlanEntry>,
710    /// Calls speccoder decided.
711    pub asked: u32,
712    /// Of them, allowed.
713    pub allowed: u32,
714    /// Of them, waited for a person.
715    pub waited: u32,
716    /// Of them, refused.
717    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/// USD for token counts (input includes cache reads and writes).
727#[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/// Keeps `live.json`, prices usage, forwards to a printer.
739#[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    /// A live record in `dir` forwarding to `sink`.
749    #[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        // Price what can be priced.
777        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                // Its conclusion: where the summary ends up.
844                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/// The target of a tool call, for one line.
944#[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        // An MCP tool: its first string argument, whatever it is called.
963        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// ── `speccoder report --live` ──────────────────────────────────────────────────────
978
979/// The project's `live.json` and whether its process is still alive.
980#[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
993/// Whether process `pid` is alive.
994pub 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
999/// `speccoder report --live`: the project's current (or last) run, from
1000/// `live.json`, in the state directory `state`.
1001pub fn status_with(path: &Path, state: &Path) -> CommandResult {
1002    status_at(path, state, agentplane::core::Timestamp::now_utc())
1003}
1004
1005/// [`status_with`] as it reads at `now`: a call whose deadline passed by
1006/// then is not listed as waiting.
1007#[doc(hidden)]
1008pub fn status_at(path: &Path, state: &Path, now: agentplane::core::Timestamp) -> CommandResult {
1009    // One inspection for both: what runs, and what waits.
1010    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
1034/// One line per call waiting for a person at `now`: what, since when,
1035/// until when. A call past its deadline waits no more — the next writer
1036/// denies it as expired — so it is not listed, as in every other listing.
1037fn waiting_lines(plane_dir: &Path, now: agentplane::core::Timestamp) -> Vec<String> {
1038    // Unreadable is said, never shown as nothing waiting. Read beside the
1039    // journal's writer (a run in another process); no journal, nothing
1040    // waits — and a read never creates one.
1041    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    // An in-place session: the mode, and for an interrupted one both ways out.
1081    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
1112/// `status` from `live.json`.
1113fn 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/// `verify`, `attempt 1/2`, or `starting`.
1178#[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/// The last lines of command output worth showing: at most five non-empty
1188/// lines, each at most 200 characters, control characters removed.
1189#[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/// [`status_with`] in the default state directory.
1205#[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}