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