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