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        /// Allowed without asking because the sandbox confines it.
142        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143        contained: bool,
144    },
145    /// Tokens so far in the current attempt.
146    Usage {
147        /// Task ID.
148        task: String,
149        /// Input tokens.
150        input_tokens: u64,
151        /// Output tokens.
152        output_tokens: u64,
153        /// Input tokens read from the cache.
154        cache_read_tokens: u64,
155        /// Input tokens written to the cache.
156        cache_write_tokens: u64,
157        /// USD, when priced.
158        usd: Option<f64>,
159    },
160    /// The task run ended.
161    TaskFinished {
162        /// Task ID.
163        task: String,
164        /// `verified`, `regression_only`, `failed`, `blocked`, …
165        outcome: String,
166        /// The run's message.
167        message: String,
168        /// The attempt's agentplane run, when one ran.
169        run: Option<String>,
170        /// Input tokens, all attempts and the acceptance call.
171        input_tokens: u64,
172        /// Output tokens.
173        output_tokens: u64,
174        /// Input tokens read from the cache.
175        cache_read_tokens: u64,
176        /// Input tokens written to the cache.
177        cache_write_tokens: u64,
178        /// USD, when priced.
179        usd: Option<f64>,
180        /// A regression-only tick: why, worded.
181        regression: Option<speccoder_core::regression::Regression>,
182    },
183    /// The attempt's changed test files were checked against what the run
184    /// collected (`collection.checked`).
185    CollectionChecked {
186        /// Task ID.
187        task: String,
188        /// Each judged file: path, state, what decided it.
189        files: Vec<CollectionFile>,
190    },
191    /// An external agent makes the attempt: its name, and what its
192    /// permission mode is (`decides_alone`: calls it makes without asking
193    /// never reach speccoder, so an empty Needs You proves nothing).
194    AgentStarted {
195        /// Task ID.
196        task: String,
197        /// The agent's name.
198        agent: String,
199        /// Its permission mode, in a sentence.
200        mode: String,
201        /// It is in a mode that decides some calls without asking.
202        decides_alone: bool,
203        /// A recording of its version says whether it asks (`false`: not
204        /// known, and said so).
205        known: bool,
206    },
207    /// An external agent changed something without asking (its own
208    /// settings let it): the count so far.
209    AgentUnasked {
210        /// Task ID.
211        task: String,
212        /// Calls it reported making without a request of speccoder's.
213        calls: u32,
214    },
215    /// An external agent's plan for its attempt, as it last sent it: its
216    /// claim of what it will do, never evidence.
217    AgentPlan {
218        /// Task ID.
219        task: String,
220        /// The entries, in its order.
221        entries: Vec<PlanEntry>,
222    },
223    /// What the model said in a turn, beside its tool calls: its
224    /// account of what it is doing — a claim, never evidence. Control
225    /// characters escaped; at most 2000 characters.
226    Said {
227        /// Task ID.
228        task: String,
229        /// Model turn (1-based) within the attempt.
230        turn: u32,
231        /// The words.
232        text: String,
233    },
234    /// The model's summary of its reasoning in a turn, as the provider
235    /// gave it (`[model] reasoning_summary`): its account of why — a claim,
236    /// never evidence. Control characters escaped; at most 4000 characters.
237    Reasoning {
238        /// Task ID.
239        task: String,
240        /// Model turn (1-based) within the attempt.
241        turn: u32,
242        /// The summary.
243        text: String,
244    },
245    /// Anything else worth a line (convergence rounds, notices).
246    Notice {
247        /// The line.
248        text: String,
249    },
250    /// A bug's step (`bug.*` on the event stream).
251    Bug {
252        /// The task (`T003`, `bug:<slug>`).
253        task: String,
254        /// What happened.
255        step: BugEvent,
256    },
257    /// The oracle at review (`oracle.*` on the event stream): stage names,
258    /// counts and digests — never a test's content, never held-out material.
259    Oracle {
260        /// What happened.
261        step: OracleEvent,
262    },
263    /// Plan through stopped (`planning.stopped`).
264    PlanningStopped {
265        /// The unit, once it exists.
266        unit: Option<String>,
267        /// `questions`, `review`, `failed`, `done`.
268        kind: String,
269        /// The skill a continuation runs next.
270        next_skill: Option<String>,
271    },
272    /// A quick change or bug fix that grew is offered as a spec of its own
273    /// (`ladder.offered`): the signals that hold, by id.
274    LadderOffered {
275        /// Its unit.
276        unit: String,
277        /// The task.
278        task: String,
279        /// `S1`…`S4`.
280        signals: Vec<String>,
281    },
282    /// A unit was promoted from a quick change or bug fix (`unit.promoted`):
283    /// ids and digests, never the words.
284    UnitPromoted {
285        /// The new unit.
286        unit: String,
287        /// Where it came from.
288        from_unit: String,
289        /// The requirement promoted.
290        requirement: String,
291        /// The task it came from.
292        task: String,
293        /// The requirement's words' digest.
294        requirement_sha256: String,
295        /// The digests of the conversations carried.
296        conversation_sha256: Vec<String>,
297        /// The signals that held.
298        signals: Vec<String>,
299        /// Who.
300        by: String,
301        /// Through what.
302        via: String,
303    },
304    /// A new project's step, its creation or its undo (`scaffold.*`,
305    /// `project.*`): step ids, argv, states, digests — never the idea,
306    /// never the folder's path.
307    Project {
308        /// What happened.
309        step: ProjectEvent,
310    },
311    /// An Ask turn read something (`ask.step`).
312    AskStep {
313        /// The conversation.
314        conversation: String,
315        /// The turn.
316        turn: u32,
317        /// The tool.
318        tool: String,
319        /// What it read, in one line.
320        target: String,
321        /// The read id the answer may cite, when it returned something.
322        read: Option<String>,
323        /// It failed or was refused.
324        refused: bool,
325    },
326    /// An Ask turn's answer as it is written (`ask.text`).
327    AskText {
328        /// The conversation.
329        conversation: String,
330        /// The turn.
331        turn: u32,
332        /// The next words.
333        delta: String,
334    },
335    /// Inside the Ask plane: a turn's raw end, which `ask::turn` turns
336    /// into [`Event::AskDone`]; never forwarded.
337    AskTurnEnded {
338        /// `ask:<conversation>#<turn>`.
339        tag: String,
340        /// The loop's output.
341        output: Box<serde_json::Value>,
342    },
343    /// An Ask turn ended (`ask.done`).
344    AskDone {
345        /// The turn as shown.
346        report: Box<speccoder_core::ask::AskReport>,
347    },
348}
349
350/// A new project's step, its creation or its undo.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
352#[serde(tag = "event", rename_all = "snake_case")]
353pub enum ProjectEvent {
354    /// A step began (progress).
355    ScaffoldStarted {
356        /// The step (`git_init`, `scaffold.1`, …).
357        step: String,
358        /// Its command.
359        argv: Vec<String>,
360        /// `local` or `network`.
361        access: String,
362    },
363    /// A step ended (progress).
364    ScaffoldFinished {
365        /// The step.
366        step: String,
367        /// `done`, `skipped`, `failed`.
368        state: String,
369        /// Its exit status, when it ran.
370        exit: Option<i32>,
371        /// How long it took.
372        duration_ms: u64,
373    },
374    /// The project was created and committed (domain).
375    Created {
376        /// SHA-256 of the folder's canonical path.
377        path_digest: String,
378        /// `new_folder` or `existing_repository`.
379        mode: String,
380        /// The stack.
381        stack: String,
382        /// The spec format.
383        format: String,
384        /// speccoder's commit.
385        commit: String,
386        /// Through what.
387        via: String,
388    },
389    /// The project speccoder created was undone (domain).
390    Undone {
391        /// SHA-256 of the folder's canonical path.
392        path_digest: String,
393        /// `trash` or `revert`.
394        how: String,
395        /// Through what.
396        via: String,
397    },
398}
399
400/// One judged file of [`Event::CollectionChecked`].
401#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
402pub struct CollectionFile {
403    /// Workspace-relative path.
404    pub path: String,
405    /// `collected`, `not_collected`, `unknown`.
406    pub state: speccoder_core::collection::FileState,
407    /// `static`, `junit`, `listing`; `null` when unknown.
408    pub decided_by: Option<speccoder_core::collection::DecidedBy>,
409}
410
411/// A step of the oracle at review.
412#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
413#[serde(tag = "kind", rename_all = "snake_case")]
414pub enum OracleEvent {
415    /// Writing tests went one stage further (progress).
416    Stage {
417        /// The requirement.
418        requirement: String,
419        /// `writing`, `comparing`, `checking_red`, `held_out`.
420        stage: String,
421        /// This stage's step.
422        n: u32,
423        /// Of.
424        of: u32,
425    },
426    /// The approval froze a requirement's tests (domain).
427    Frozen {
428        /// The unit.
429        unit: String,
430        /// The requirement.
431        requirement: String,
432        /// The visible set's manifest digest.
433        manifest_sha256: String,
434        /// The approval's digest.
435        approval_sha256: String,
436        /// Visible tests frozen.
437        visible_count: usize,
438        /// Held-out tests sealed with them.
439        held_out_count: usize,
440        /// Who.
441        by: String,
442        /// Through what.
443        via: String,
444    },
445    /// A frozen oracle lapsed with its requirement's wording (domain).
446    Lapsed {
447        /// The unit.
448        unit: String,
449        /// The requirement.
450        requirement: String,
451        /// The wording it was frozen on.
452        frozen_wording_sha256: String,
453        /// The wording now.
454        current_wording_sha256: String,
455    },
456}
457
458/// One statement's class in a reproduce try.
459#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
460pub struct StatementClass {
461    /// `E1`, `U1`.
462    pub statement: String,
463    /// The red class (`assertion`, `passed`, …).
464    pub class: String,
465}
466
467/// A reproduction as the stream shows it: never its content.
468#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
469pub struct ShownReproduction {
470    /// Its statement.
471    pub statement: String,
472    /// Its file.
473    pub file: String,
474    /// Its test.
475    pub test: String,
476    /// Its content's digest.
477    pub digest: String,
478}
479
480/// A special case as the stream shows it.
481#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
482pub struct ShownSpecialCase {
483    /// The statement it targets.
484    pub statement: String,
485    /// Rejected by the combined set.
486    pub rejected: bool,
487}
488
489/// A bug's step.
490#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
491#[serde(tag = "kind", rename_all = "snake_case")]
492pub enum BugEvent {
493    /// The triad is recorded (domain).
494    Recorded {
495        /// The home's kind.
496        home: String,
497        /// The unit.
498        unit: String,
499        /// The requirement.
500        requirement: String,
501        /// The triad's digest.
502        triad_digest: String,
503        /// Through what.
504        via: String,
505    },
506    /// One reproduce try ended.
507    ReproduceTry {
508        /// The try.
509        n: u32,
510        /// Of at most.
511        of: u32,
512        /// Per statement.
513        per_statement: Vec<StatementClass>,
514    },
515    /// Every statement is reproduced.
516    Reproduced {
517        /// The reproductions.
518        reproductions: Vec<ShownReproduction>,
519    },
520    /// The reproductions wait for the person.
521    ConfirmWaiting {
522        /// The digest to confirm.
523        digest: String,
524        /// The command.
525        next: String,
526    },
527    /// The person confirmed them (domain).
528    Confirmed {
529        /// The digest.
530        digest: String,
531        /// Who.
532        by: String,
533        /// Through what.
534        via: String,
535        /// `shown` or `named_test`.
536        basis: String,
537    },
538    /// The person rejected them (domain).
539    ConfirmRejected {
540        /// The digest.
541        digest: String,
542        /// Why.
543        reason: String,
544    },
545    /// The pass-to-pass set was chosen.
546    UnchangedSelected {
547        /// `selected` or `none_selected`.
548        state: String,
549        /// Members.
550        count: usize,
551    },
552    /// The revert and special cases were judged.
553    Discrimination {
554        /// `rejected`, `survived`, `unviable`.
555        revert: String,
556        /// The special cases.
557        special_case: Vec<ShownSpecialCase>,
558    },
559    /// The bug's outcome (domain).
560    Outcome {
561        /// `awaiting_confirmation`, `cannot_reproduce`, …
562        outcome: String,
563        /// The fix's verdict.
564        verdict: Option<String>,
565        /// The stop's code.
566        code: Option<String>,
567        /// What to do next.
568        next: Vec<String>,
569    },
570}
571
572impl BugEvent {
573    /// Its name on the event stream (registered in
574    /// `speccoder_core::event::REGISTRY`).
575    #[must_use]
576    pub const fn name(&self) -> &'static str {
577        match self {
578            Self::Recorded { .. } => "bug.recorded",
579            Self::ReproduceTry { .. } => "bug.reproduce_try",
580            Self::Reproduced { .. } => "bug.reproduced",
581            Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
582            Self::Confirmed { .. } => "bug.confirmed",
583            Self::ConfirmRejected { .. } => "bug.confirm_rejected",
584            Self::UnchangedSelected { .. } => "bug.unchanged_selected",
585            Self::Discrimination { .. } => "bug.discrimination",
586            Self::Outcome { .. } => "bug.outcome",
587        }
588    }
589
590    /// The line a plain progress printer shows.
591    #[must_use]
592    pub fn line(&self) -> String {
593        match self {
594            Self::Recorded {
595                unit, requirement, ..
596            } => format!("bug recorded as {requirement} in {unit}"),
597            Self::ReproduceTry {
598                n,
599                of,
600                per_statement,
601            } => format!(
602                "reproduce try {n}/{of}: {}",
603                per_statement
604                    .iter()
605                    .map(|s| format!("{} {}", s.statement, s.class))
606                    .collect::<Vec<_>>()
607                    .join(", ")
608            ),
609            Self::Reproduced { reproductions } => format!(
610                "reproduced: {} test(s) fail on an assertion",
611                reproductions
612                    .iter()
613                    .filter(|r| r.statement.starts_with('E'))
614                    .count()
615            ),
616            Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
617            Self::Confirmed { by, basis, .. } => {
618                format!("reproductions confirmed by {by} ({basis})")
619            }
620            Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
621            Self::UnchangedSelected { state, count } => {
622                format!("pass-to-pass: {count} test(s) ({state})")
623            }
624            Self::Discrimination {
625                revert,
626                special_case,
627            } => format!(
628                "revert {revert}; {} special case(s), {} rejected",
629                special_case.len(),
630                special_case.iter().filter(|s| s.rejected).count()
631            ),
632            Self::Outcome {
633                outcome, verdict, ..
634            } => match verdict {
635                Some(v) => format!("bug {outcome}: {v}"),
636                None => format!("bug {outcome}"),
637            },
638        }
639    }
640}
641
642/// Receives events as they happen.
643pub trait Progress: Send + Sync + std::fmt::Debug {
644    /// One event.
645    fn event(&self, event: &Event);
646}
647
648/// What `live.json` holds.
649#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
650pub struct LiveState {
651    /// The speccoder process.
652    pub pid: u32,
653    /// Feature directory name.
654    pub feature: String,
655    /// Task ID.
656    pub task: String,
657    /// Task title.
658    pub title: String,
659    /// The current stage.
660    #[serde(default)]
661    pub stage: Option<Stage>,
662    /// Its detail (`1/2`, a commit id).
663    #[serde(default)]
664    pub stage_detail: Option<String>,
665    /// The last tool call, e.g. `edit_file src/lib.rs (allow)`.
666    pub last_call: Option<String>,
667    /// What the model last said, its first line (a claim).
668    #[serde(default)]
669    pub said: Option<String>,
670    /// The last line of the model's latest reasoning summary (a claim).
671    #[serde(default)]
672    pub thinking: Option<String>,
673    /// The latest tool calls, oldest first (at most [`RECENT`]).
674    #[serde(default)]
675    pub recent: Vec<String>,
676    /// Commands the sandbox confined, run without asking, this attempt.
677    #[serde(default)]
678    pub contained: u32,
679    /// Tokens of the current attempt.
680    pub input_tokens: u64,
681    /// Output tokens of the current attempt.
682    pub output_tokens: u64,
683    /// USD of the current attempt, when priced.
684    pub usd: Option<f64>,
685    /// Set when the task run ended: its outcome and message.
686    pub finished: Option<String>,
687    /// Unix seconds when the task run started.
688    pub started_at: u64,
689    /// Unix seconds of the last event.
690    pub updated_at: u64,
691    /// The external agent making the attempt, when one is.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub agent: Option<LiveAgent>,
694}
695
696/// An external agent's attempt as it goes: its plan, and what speccoder
697/// decided of what it asked.
698#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
699pub struct LiveAgent {
700    /// Its name.
701    pub name: String,
702    /// Its permission mode, in a sentence.
703    pub mode: String,
704    /// It decides some calls without asking (its mode, or calls it was
705    /// seen to make unasked).
706    pub decides_alone: bool,
707    /// A recording says whether it asks.
708    #[serde(default)]
709    pub known: bool,
710    /// Calls it made without asking.
711    #[serde(default)]
712    pub unasked: u32,
713    /// Its plan, as it last sent it.
714    #[serde(default)]
715    pub plan: Vec<PlanEntry>,
716    /// Calls speccoder decided.
717    pub asked: u32,
718    /// Of them, allowed.
719    pub allowed: u32,
720    /// Of them, waited for a person.
721    pub waited: u32,
722    /// Of them, refused.
723    pub refused: u32,
724}
725
726fn now() -> u64 {
727    std::time::SystemTime::now()
728        .duration_since(std::time::UNIX_EPOCH)
729        .map_or(0, |d| d.as_secs())
730}
731
732/// USD for token counts (input includes cache reads and writes).
733#[must_use]
734pub fn usd(
735    prices: Option<Prices>,
736    input: u64,
737    output: u64,
738    cache_read: u64,
739    cache_write: u64,
740) -> Option<f64> {
741    prices.map(|p| p.usd(input, output, cache_read, cache_write))
742}
743
744/// Keeps `live.json`, prices usage, forwards to a printer.
745#[derive(Debug)]
746pub struct Live {
747    file: PathBuf,
748    prices: Option<Prices>,
749    sink: Option<Arc<dyn Progress>>,
750    state: Mutex<LiveState>,
751}
752
753impl Live {
754    /// A live record in `dir` forwarding to `sink`.
755    #[must_use]
756    pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
757        let _ = std::fs::create_dir_all(dir);
758        Self {
759            file: dir.join(FILE),
760            prices,
761            sink,
762            state: Mutex::new(LiveState {
763                pid: std::process::id(),
764                ..LiveState::default()
765            }),
766        }
767    }
768
769    fn write(&self, state: &LiveState) {
770        if let Ok(text) = serde_json::to_string_pretty(state) {
771            let tmp = self.file.with_extension("json.tmp");
772            if std::fs::write(&tmp, text).is_ok() {
773                let _ = std::fs::rename(&tmp, &self.file);
774            }
775        }
776    }
777}
778
779impl Progress for Live {
780    fn event(&self, event: &Event) {
781        let mut event = event.clone();
782        // Price what can be priced.
783        match &mut event {
784            Event::Usage {
785                input_tokens,
786                output_tokens,
787                cache_read_tokens,
788                cache_write_tokens,
789                usd: price,
790                ..
791            } => {
792                *price = usd(
793                    self.prices,
794                    *input_tokens,
795                    *output_tokens,
796                    *cache_read_tokens,
797                    *cache_write_tokens,
798                )
799            }
800            Event::TaskFinished {
801                input_tokens,
802                output_tokens,
803                cache_read_tokens,
804                cache_write_tokens,
805                usd: price,
806                ..
807            } => {
808                *price = usd(
809                    self.prices,
810                    *input_tokens,
811                    *output_tokens,
812                    *cache_read_tokens,
813                    *cache_write_tokens,
814                )
815            }
816            _ => {}
817        }
818        if let Ok(mut s) = self.state.lock() {
819            match &event {
820                Event::TaskStarted {
821                    feature,
822                    task,
823                    title,
824                } => {
825                    *s = LiveState {
826                        pid: s.pid,
827                        feature: feature.clone(),
828                        task: task.clone(),
829                        title: title.clone(),
830                        started_at: now(),
831                        ..LiveState::default()
832                    };
833                }
834                Event::Stage {
835                    stage,
836                    state: StageState::Started,
837                    detail,
838                    ..
839                } => {
840                    s.stage = Some(*stage);
841                    s.stage_detail.clone_from(detail);
842                }
843                Event::Said { text, .. } => {
844                    s.said = text
845                        .lines()
846                        .find(|l| !l.trim().is_empty())
847                        .map(|l| l.chars().take(160).collect());
848                }
849                // Its conclusion: where the summary ends up.
850                Event::Reasoning { text, .. } => {
851                    s.thinking = text
852                        .lines()
853                        .rev()
854                        .find(|l| !l.trim().is_empty())
855                        .map(|l| l.chars().take(160).collect());
856                }
857                Event::AgentStarted {
858                    agent,
859                    mode,
860                    decides_alone,
861                    known,
862                    ..
863                } => {
864                    s.agent = Some(LiveAgent {
865                        name: agent.clone(),
866                        mode: mode.clone(),
867                        decides_alone: *decides_alone,
868                        known: *known,
869                        ..LiveAgent::default()
870                    });
871                }
872                Event::AgentUnasked { calls, .. } => {
873                    if let Some(a) = s.agent.as_mut() {
874                        a.unasked = *calls;
875                        a.decides_alone = true;
876                    }
877                }
878                Event::AgentPlan { entries, .. } => {
879                    if let Some(a) = s.agent.as_mut() {
880                        a.plan.clone_from(entries);
881                    }
882                }
883                Event::ApprovalRequested { .. } => {
884                    if let Some(a) = s.agent.as_mut() {
885                        a.waited += 1;
886                    }
887                }
888                Event::Stage { .. } | Event::Resumed { .. } | Event::ApprovalDecided { .. } => {}
889                Event::ToolCall {
890                    turn,
891                    tool,
892                    target,
893                    decision,
894                    contained,
895                    ..
896                } => {
897                    let decision = if *contained {
898                        s.contained += 1;
899                        "contained"
900                    } else {
901                        decision.as_str()
902                    };
903                    if let Some(a) = s.agent.as_mut()
904                        && decision != "reported"
905                    {
906                        a.asked += 1;
907                        match decision {
908                            "allow" => a.allowed += 1,
909                            "deny" => a.refused += 1,
910                            _ => {}
911                        }
912                    }
913                    let call = format!("{tool} {target} ({decision})");
914                    s.recent.push(format!("turn {turn}  {call}"));
915                    if s.recent.len() > RECENT {
916                        s.recent.remove(0);
917                    }
918                    s.last_call = Some(call);
919                }
920                Event::Usage {
921                    input_tokens,
922                    output_tokens,
923                    usd,
924                    ..
925                } => {
926                    s.input_tokens = *input_tokens;
927                    s.output_tokens = *output_tokens;
928                    s.usd = *usd;
929                }
930                Event::TaskFinished {
931                    outcome, message, ..
932                } => s.finished = Some(format!("{outcome}: {message}")),
933                Event::Notice { .. }
934                | Event::RunStarted { .. }
935                | Event::Bug { .. }
936                | Event::Oracle { .. }
937                | Event::CollectionChecked { .. }
938                | Event::PlanningStopped { .. }
939                | Event::LadderOffered { .. }
940                | Event::UnitPromoted { .. }
941                | Event::Project { .. }
942                | Event::AskStep { .. }
943                | Event::AskText { .. }
944                | Event::AskTurnEnded { .. }
945                | Event::AskDone { .. } => {}
946            }
947            s.updated_at = now();
948            self.write(&s);
949        }
950        if let Some(sink) = &self.sink {
951            sink.event(&event);
952        }
953    }
954}
955
956/// The target of a tool call, for one line.
957#[must_use]
958pub fn target(tool: &str, args: &serde_json::Value) -> String {
959    let text = match tool {
960        "run_command" => args["argv"]
961            .as_array()
962            .map(|a| {
963                a.iter()
964                    .filter_map(|v| v.as_str())
965                    .collect::<Vec<_>>()
966                    .join(" ")
967            })
968            .unwrap_or_default(),
969        "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
970        "report_blocked" => format!(
971            "{}: {}",
972            args["kind"].as_str().unwrap_or("?"),
973            args["evidence"].as_str().unwrap_or_default()
974        ),
975        // An MCP tool: its first string argument, whatever it is called.
976        name if name.starts_with(crate::mcp::PREFIX) => args
977            .as_object()
978            .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
979            .unwrap_or_default(),
980        _ => args["path"].as_str().unwrap_or_default().to_owned(),
981    };
982    let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
983    if one.chars().count() > 60 {
984        format!("{}…", one.chars().take(59).collect::<String>())
985    } else {
986        one
987    }
988}
989
990// ── `speccoder report --live` ──────────────────────────────────────────────────────
991
992/// The project's `live.json` and whether its process is still alive.
993#[must_use]
994pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
995    let root = match crate::inspect(path, None).data() {
996        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
997        _ => return None,
998    };
999    let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
1000        .ok()?;
1001    let live: LiveState = serde_json::from_str(&text).ok()?;
1002    let running = live.finished.is_none() && alive(live.pid);
1003    Some((live, running))
1004}
1005
1006/// Whether process `pid` is alive.
1007pub fn alive(pid: u32) -> bool {
1008    i32::try_from(pid)
1009        .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
1010}
1011
1012/// `speccoder report --live`: the project's current (or last) run, from
1013/// `live.json`, in the state directory `state`.
1014pub fn status_with(path: &Path, state: &Path) -> CommandResult {
1015    status_at(path, state, agentplane::core::Timestamp::now_utc())
1016}
1017
1018/// [`status_with`] as it reads at `now`: a call whose deadline passed by
1019/// then is not listed as waiting.
1020#[doc(hidden)]
1021pub fn status_at(path: &Path, state: &Path, now: agentplane::core::Timestamp) -> CommandResult {
1022    // One inspection for both: what runs, and what waits.
1023    let inspected = crate::inspect(path, None);
1024    let root = match inspected.data() {
1025        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
1026        _ => return CommandResult::carried("report", &inspected),
1027    };
1028    let plane_dir = crate::worktree::project_state_dir(state, &root);
1029    let res = status_inner(&plane_dir);
1030    let lines = waiting_lines(&plane_dir, now);
1031    if lines.is_empty() {
1032        return res;
1033    }
1034    let mut next = res.next().to_vec();
1035    next.insert(
1036        0,
1037        speccoder_core::next::NextStep {
1038            command: "speccoder report --approvals".to_owned(),
1039            why: "see the waiting calls, then approve or reject them".to_owned(),
1040        },
1041    );
1042    let message = format!("{}\n{}", res.message(), lines.join("\n"));
1043    let status = res.status();
1044    res.reworded(status, &message).with_next(next)
1045}
1046
1047/// One line per call waiting for a person at `now`: what, since when,
1048/// until when. A call past its deadline waits no more — the next writer
1049/// denies it as expired — so it is not listed, as in every other listing.
1050fn waiting_lines(plane_dir: &Path, now: agentplane::core::Timestamp) -> Vec<String> {
1051    // Unreadable is said, never shown as nothing waiting. Read beside the
1052    // journal's writer (a run in another process); no journal, nothing
1053    // waits — and a read never creates one.
1054    let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
1055    let rt = match crate::tokio_runtime() {
1056        Ok(rt) => rt,
1057        Err(e) => return unknown(e),
1058    };
1059    let plane = match crate::substrate::Plane::open_for_reading(plane_dir) {
1060        Ok(Some(p)) => p,
1061        Ok(None) => return Vec::new(),
1062        Err(e) => return unknown(e.0),
1063    };
1064    let open = match rt.block_on(plane.open_tasks()) {
1065        Ok(open) => open,
1066        Err(e) => return unknown(e.0),
1067    };
1068    open.into_iter()
1069        .filter(|t| crate::approvals::still_waits(t.due_at, now))
1070        .filter_map(|t| {
1071            let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
1072            let since = (now - t.created_at).whole_minutes().max(0);
1073            let until = t
1074                .due_at
1075                .map(|d| {
1076                    format!(
1077                        ", denied as expired in {} h",
1078                        (d - now).whole_hours().max(0)
1079                    )
1080                })
1081                .unwrap_or_default();
1082            Some(format!(
1083                "waiting for a person: {} — {} (for {since} min{until})",
1084                request.task,
1085                request.covers()
1086            ))
1087        })
1088        .collect()
1089}
1090
1091fn status_inner(plane_dir: &Path) -> CommandResult {
1092    let plane_dir = plane_dir.to_path_buf();
1093    // An in-place session: the mode, and for an interrupted one both ways out.
1094    if let Some(s) = crate::in_place::Session::load(&plane_dir) {
1095        let mode = s.mode_line();
1096        if !alive(s.pid) {
1097            let step = |command: &str, why: &str| speccoder_core::next::NextStep {
1098                command: command.to_owned(),
1099                why: why.to_owned(),
1100            };
1101            return CommandResult::new(
1102                "status",
1103                CommandStatus::Ok,
1104                &format!(
1105                    "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
1106                    s.feature, s.pid, s.branch
1107                ),
1108            )
1109            .with_next(vec![
1110                step("speccoder run --in-place", "resume where it stopped"),
1111                step(
1112                    "speccoder run --in-place --return",
1113                    "save what it left and switch back without running",
1114                ),
1115            ]);
1116        }
1117        let res = status_live(&plane_dir);
1118        let message = format!("{}\n{mode}", res.message());
1119        let status = res.status();
1120        return res.reworded(status, &message);
1121    }
1122    status_live(&plane_dir)
1123}
1124
1125/// `status` from `live.json`.
1126fn status_live(plane_dir: &Path) -> CommandResult {
1127    let file = plane_dir.join(FILE);
1128    let live: LiveState = match std::fs::read_to_string(&file)
1129        .ok()
1130        .and_then(|t| serde_json::from_str(&t).ok())
1131    {
1132        Some(l) => l,
1133        None => {
1134            return CommandResult::new(
1135                "status",
1136                CommandStatus::Ok,
1137                "no run has been recorded for this project yet",
1138            );
1139        }
1140    };
1141    let ago = now().saturating_sub(live.updated_at);
1142    let tokens = format!(
1143        "{} in · {} out{}",
1144        live.input_tokens,
1145        live.output_tokens,
1146        live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
1147    );
1148    use speccoder_core::next::{Outcome, next};
1149    let outcome = match (&live.finished, alive(live.pid)) {
1150        (None, true) => Some(Outcome::Running),
1151        (None, false) => Some(Outcome::Interrupted {
1152            task: live.task.clone(),
1153        }),
1154        (Some(_), _) => None,
1155    };
1156    let message = match (&live.finished, alive(live.pid)) {
1157        (None, true) => format!(
1158            "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
1159            live.pid,
1160            live.feature,
1161            live.task,
1162            stage_word(&live),
1163            now().saturating_sub(live.started_at),
1164            live.last_call.as_deref().unwrap_or("-"),
1165            live.thinking
1166                .as_deref()
1167                .map(|s| format!("\nthe model thinks (a claim): {s}"))
1168                .unwrap_or_default(),
1169            live.said
1170                .as_deref()
1171                .map(|s| format!("\nthe model says (a claim): “{s}”"))
1172                .unwrap_or_default(),
1173        ),
1174        (None, false) => format!(
1175            "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
1176            live.feature,
1177            live.task,
1178            stage_word(&live),
1179            live.pid
1180        ),
1181        (Some(outcome), _) => format!(
1182            "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
1183            live.feature, live.task
1184        ),
1185    };
1186    CommandResult::new("status", CommandStatus::Ok, &message)
1187        .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
1188}
1189
1190/// `verify`, `attempt 1/2`, or `starting`.
1191#[must_use]
1192pub fn stage_word(live: &LiveState) -> String {
1193    match (live.stage, &live.stage_detail) {
1194        (Some(st), Some(d)) => format!("{} {d}", st.name()),
1195        (Some(st), None) => st.name().to_owned(),
1196        (None, _) => "starting".to_owned(),
1197    }
1198}
1199
1200/// The last lines of command output worth showing: at most five non-empty
1201/// lines, each at most 200 characters, control characters removed.
1202#[must_use]
1203pub fn tail(text: &str) -> Vec<String> {
1204    let lines: Vec<String> = text
1205        .lines()
1206        .map(|l| {
1207            l.chars()
1208                .filter(|c| !c.is_control())
1209                .take(200)
1210                .collect::<String>()
1211        })
1212        .filter(|l| !l.trim().is_empty())
1213        .collect();
1214    lines[lines.len().saturating_sub(5)..].to_vec()
1215}
1216
1217/// [`status_with`] in the default state directory.
1218#[must_use]
1219pub fn status(path: &Path) -> CommandResult {
1220    match crate::worktree::state_dir() {
1221        Some(state) => status_with(path, &state),
1222        None => CommandResult::new(
1223            "status",
1224            CommandStatus::ConfigError,
1225            "no state directory (set SPECCODER_STATE_DIR or HOME)",
1226        )
1227        .with_code("NO-STATE-DIR"),
1228    }
1229}