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