Skip to main content

speccoder_runtime/
approvals.rs

1//! A person's approvals and rejections.
2//!
3//! When an attempt stops for approval, its pending call is saved with the
4//! attempt. `speccoder task approve` turns that call's resolved object —
5//! the canonical file of a write, the exact argv of a command with its
6//! directory and executable, an MCP tool with its definition's digest —
7//! into an approval for the task, bound to the run it was made in (a grant
8//! never outlives its run); `speccoder task reject` into a denial with a
9//! reason the model is told. The next attempt carries the task's decisions
10//! in its journaled input, so strict replay needs nothing else. Standing
11//! approvals for commands are `[commands] allow` in `speccoder.toml`
12//! (exact argvs), committed by the person.
13
14use std::collections::BTreeMap;
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18use speccoder_core::actions::Via;
19use speccoder_core::{
20    ApprovalsReport, CommandData, CommandResult, CommandStatus, DecidedCall, PendingCall,
21};
22
23use crate::tools::policy::{Object, Rejection};
24
25/// The decisions file in a project's state directory.
26pub const FILE: &str = "approvals.json";
27
28/// An approved object and the run it belongs to: it covers calls of that
29/// run only. `run` is `None` for a decision on a call a parallel lane
30/// stopped (its run ended): the next run that attempts the task claims it
31/// ([`Decisions::claim`]), and it ends with that run.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct RunGrant {
35    /// What it covers.
36    pub object: Object,
37    /// The run (`run-<secs>-<hex>`), once claimed.
38    #[serde(default)]
39    pub run: Option<String>,
40    /// It covers one call: the next attempt of the task in its run is
41    /// given it ([`Decisions::take_once`]) and may make the call one time.
42    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
43    pub once: bool,
44}
45
46/// One task's decisions.
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct TaskDecisions {
50    /// Approved objects, each with its run.
51    #[serde(default)]
52    pub approved: Vec<RunGrant>,
53    /// Rejected objects with reasons (a rejection holds for the task).
54    #[serde(default)]
55    pub rejected: Vec<Rejection>,
56    /// A person's notes for the task's next attempts (`speccoder task nudge`).
57    #[serde(default)]
58    pub nudges: Vec<String>,
59    /// Every decision, in order, with who made it.
60    #[serde(default)]
61    pub decided: Vec<Decided>,
62}
63
64/// Who decided, through what, and how speccoder knows the name.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(from = "WhoRecord")]
67pub struct Who {
68    /// The person (see [`who`]).
69    pub name: String,
70    /// The interface ([`Via::as_str`]): `terminal`, `web`, `acp`, …
71    pub via: String,
72    /// The editor's name, over the Agent Client Protocol.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub client: Option<String>,
75    /// `asserted` (typed at a local terminal or the CLI) or `connected`
76    /// (reported by a client over its connection). speccoder authenticates
77    /// nobody, so never `authenticated`.
78    pub basis: String,
79}
80
81/// [`Who`] as recorded, by this version or an earlier one (`cli`,
82/// `dashboard`, `editor <name>`).
83#[derive(Deserialize)]
84struct WhoRecord {
85    name: String,
86    via: String,
87    #[serde(default)]
88    client: Option<String>,
89    #[serde(default = "asserted")]
90    basis: String,
91}
92
93impl From<WhoRecord> for Who {
94    fn from(r: WhoRecord) -> Self {
95        let (via, client) = match Via::read(&r.via) {
96            Some((v, named)) => (v.as_str().to_owned(), named.or(r.client)),
97            None => (r.via, r.client),
98        };
99        Self {
100            name: r.name,
101            via,
102            client,
103            basis: r.basis,
104        }
105    }
106}
107
108fn asserted() -> String {
109    "asserted".to_owned()
110}
111
112impl Who {
113    /// The local person, deciding through `via`: the name is the person's
114    /// own (`asserted`).
115    #[must_use]
116    pub fn local(via: Via) -> Self {
117        Self::local_in(Path::new("."), via)
118    }
119
120    /// [`Who::local`], named as git names the person in `dir` (the project).
121    #[must_use]
122    pub fn local_in(dir: &Path, via: Via) -> Self {
123        Self {
124            name: who_in(dir),
125            via: via.as_str().to_owned(),
126            client: None,
127            basis: "asserted".to_owned(),
128        }
129    }
130
131    /// The local person, deciding in the editor `client` over its
132    /// connection (`acp`): the name is the connection's (`connected`).
133    #[must_use]
134    pub fn connected(client: &str) -> Self {
135        Self {
136            name: who(),
137            via: Via::Acp.as_str().to_owned(),
138            client: Some(client.to_owned()),
139            basis: "connected".to_owned(),
140        }
141    }
142
143    /// One line: `hupe (terminal)`.
144    #[must_use]
145    pub fn describe(&self) -> String {
146        format!("{} ({})", self.name, self.via)
147    }
148
149    /// agentplane's operator: the name and its basis.
150    #[must_use]
151    pub fn operator(&self) -> Option<agentplane::core::Operator> {
152        agentplane::core::Operator::from_parts(&self.name, &self.basis).ok()
153    }
154
155    /// The operator as reports show it.
156    #[must_use]
157    pub fn view(&self) -> speccoder_core::OperatorView {
158        speccoder_core::OperatorView {
159            actor: self.name.clone(),
160            basis: self.basis.clone(),
161        }
162    }
163}
164
165/// The local person's name, the same for every decision and attestation:
166/// git's `user.name` (what their commits say), else the operating-system
167/// user, else `unknown` — never a guess.
168#[must_use]
169pub fn who() -> String {
170    who_in(Path::new("."))
171}
172
173/// [`who`], reading git's configuration as it applies in `dir`.
174#[must_use]
175pub fn who_in(dir: &Path) -> String {
176    crate::git::Git::at(dir)
177        .line(&["config", "user.name"])
178        .ok()
179        .filter(|n| !n.trim().is_empty())
180        .or_else(|| {
181            ["USER", "USERNAME", "LOGNAME"]
182                .iter()
183                .find_map(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty()))
184        })
185        .unwrap_or_else(|| "unknown".to_owned())
186}
187
188/// The object-bound grants a person approved earlier for this
189/// feature's tasks — distinct, by grant id, in the order first decided —
190/// with the objects themselves: what an autopilot run may be given.
191#[must_use]
192pub fn offered_objects(
193    project_state: &Path,
194    feature: &str,
195) -> Vec<(speccoder_core::oracle::OfferedGrant, Object)> {
196    let Ok(decisions) = Decisions::load(project_state) else {
197        return Vec::new();
198    };
199    let prefix = format!("{feature}/");
200    let mut out: Vec<(speccoder_core::oracle::OfferedGrant, Object)> = Vec::new();
201    for (_, task) in decisions
202        .tasks
203        .iter()
204        .filter(|(k, _)| k.starts_with(&prefix))
205    {
206        for d in task.decided.iter().filter(|d| d.verb == "approve") {
207            let Some(o) = &d.object else { continue };
208            let id = o.grant_id();
209            if out.iter().any(|(g, _)| g.id == id) {
210                continue;
211            }
212            let object = match o {
213                Object::Command { argv_sha256, .. } => {
214                    format!(
215                        "{} · argv #{}",
216                        o.describe(),
217                        argv_sha256.get(..12).unwrap_or(argv_sha256)
218                    )
219                }
220                _ => o.describe(),
221            };
222            out.push((
223                speccoder_core::oracle::OfferedGrant {
224                    id,
225                    class: o.class().as_str().to_owned(),
226                    object,
227                },
228                o.clone(),
229            ));
230        }
231    }
232    out
233}
234
235/// [`offered_objects`] as the sheet shows them.
236#[must_use]
237pub fn offered_grants(
238    project_state: &Path,
239    feature: &str,
240) -> Vec<speccoder_core::oracle::OfferedGrant> {
241    offered_objects(project_state, feature)
242        .into_iter()
243        .map(|(g, _)| g)
244        .collect()
245}
246
247/// One decision, as it was made.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Decided {
251    /// `approve`, `reject` or `resume_asking` (a run's asking budget
252    /// started again).
253    pub verb: String,
254    /// The object decided on; none for `resume_asking`.
255    #[serde(default)]
256    pub object: Option<Object>,
257    /// The run it was made in.
258    #[serde(default)]
259    pub run: Option<String>,
260    /// A rejection's reason.
261    #[serde(default)]
262    pub reason: Option<String>,
263    /// How far an approval reaches: `once`, `task` or `run` (a rejection
264    /// holds for the task).
265    #[serde(default = "task_scope")]
266    pub scope: String,
267    /// An approval's note to the model.
268    #[serde(default)]
269    pub note: Option<String>,
270    /// Who decided.
271    pub by: Who,
272    /// Seconds since the epoch.
273    pub at: u64,
274}
275
276fn task_scope() -> String {
277    "task".to_owned()
278}
279
280fn now() -> u64 {
281    std::time::SystemTime::now()
282        .duration_since(std::time::UNIX_EPOCH)
283        .map_or(0, |d| d.as_secs())
284}
285
286/// All decisions, by `feature/task`.
287#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
288pub struct Decisions {
289    /// By `feature/task`.
290    #[serde(default)]
291    pub tasks: BTreeMap<String, TaskDecisions>,
292}
293
294impl Decisions {
295    /// Reads the decisions in `dir`; none when there is no file yet. A file
296    /// in an earlier shape (grants by class, not object) is an error naming
297    /// it: reading it as "no decisions" would drop its rejections.
298    pub fn load(dir: &Path) -> Result<Self, String> {
299        match std::fs::read_to_string(dir.join(FILE)) {
300            Ok(text) => serde_json::from_str(&text).map_err(|e| {
301                format!(
302                    "{}: {e} — written before decisions named objects; remove it to start afresh (its decisions are not carried over)",
303                    dir.join(FILE).display()
304                )
305            }),
306            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
307            Err(e) => Err(format!("{}: {e}", dir.join(FILE).display())),
308        }
309    }
310
311    /// Writes the decisions to `dir` (temporary file, then rename).
312    pub fn save(&self, dir: &Path) -> Result<(), String> {
313        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
314        let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
315        let tmp = dir.join(format!("{FILE}.tmp"));
316        std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
317        std::fs::rename(&tmp, dir.join(FILE)).map_err(|e| format!("{}: {e}", dir.display()))
318    }
319
320    /// The decisions for `feature/task`.
321    #[must_use]
322    pub fn for_task(&self, feature: &str, task: &str) -> TaskDecisions {
323        self.tasks
324            .get(&format!("{feature}/{task}"))
325            .cloned()
326            .unwrap_or_default()
327    }
328
329    /// The objects approved for `feature/task` in `run`: never another
330    /// run's (a grant ends with its run).
331    #[must_use]
332    pub fn for_run(&self, feature: &str, task: &str, run: &str) -> Vec<Object> {
333        self.for_task(feature, task)
334            .approved
335            .into_iter()
336            .filter(|g| !g.once && g.run.as_deref() == Some(run))
337            .map(|g| g.object)
338            .collect()
339    }
340
341    /// Removes and returns the once-grants of `feature/task` claimed by
342    /// `run`: the attempt they are handed to may make each call one time,
343    /// and no later attempt has them.
344    pub fn take_once(&mut self, feature: &str, task: &str, run: &str) -> Vec<Object> {
345        let Some(d) = self.tasks.get_mut(&format!("{feature}/{task}")) else {
346            return Vec::new();
347        };
348        let (once, kept): (Vec<RunGrant>, Vec<RunGrant>) = std::mem::take(&mut d.approved)
349            .into_iter()
350            .partition(|g| g.once && g.run.as_deref() == Some(run));
351        d.approved = kept;
352        once.into_iter().map(|g| g.object).collect()
353    }
354
355    /// Records an approval of `object` for one call of the task's next
356    /// attempt (a call a lane stopped, decided in its run's batch); a
357    /// rejection of the same object is withdrawn.
358    pub fn approve_once(&mut self, feature: &str, task: &str, object: Object, by: &Who) {
359        let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
360        d.rejected.retain(|r| r.object != object);
361        let grant = RunGrant {
362            object: object.clone(),
363            run: None,
364            once: true,
365        };
366        if !d.approved.contains(&grant) {
367            d.approved.push(grant);
368        }
369        d.decided.push(Decided {
370            verb: "approve".to_owned(),
371            object: Some(object),
372            run: None,
373            reason: None,
374            scope: "once".to_owned(),
375            note: None,
376            by: by.clone(),
377            at: now(),
378        });
379    }
380
381    /// Binds the task's unclaimed grants (decisions on a call a lane
382    /// stopped) to `run`, the run that now attempts it; whether any was.
383    pub fn claim(&mut self, feature: &str, task: &str, run: &str) -> bool {
384        let Some(d) = self.tasks.get_mut(&format!("{feature}/{task}")) else {
385            return false;
386        };
387        let mut claimed = false;
388        for g in d.approved.iter_mut().filter(|g| g.run.is_none()) {
389            g.run = Some(run.to_owned());
390            claimed = true;
391        }
392        claimed
393    }
394
395    /// Records an approval of `object` in `run`; a rejection of the same
396    /// object is withdrawn.
397    pub fn approve(
398        &mut self,
399        feature: &str,
400        task: &str,
401        object: Object,
402        run: Option<&str>,
403        by: &Who,
404    ) {
405        let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
406        d.rejected.retain(|r| r.object != object);
407        let grant = RunGrant {
408            object: object.clone(),
409            run: run.map(str::to_owned),
410            once: false,
411        };
412        if !d.approved.contains(&grant) {
413            d.approved.push(grant);
414        }
415        d.decided.push(Decided {
416            verb: "approve".to_owned(),
417            object: Some(object),
418            run: run.map(str::to_owned),
419            reason: None,
420            scope: task_scope(),
421            note: None,
422            by: by.clone(),
423            at: now(),
424        });
425    }
426
427    /// Records a decision that changes nothing for the task (an inline
428    /// approval or rejection of one call): the history only.
429    pub fn note(&mut self, feature: &str, task: &str, decided: Decided) {
430        self.tasks
431            .entry(format!("{feature}/{task}"))
432            .or_default()
433            .decided
434            .push(decided);
435    }
436
437    /// Records a rejection; an approval of the same object is withdrawn.
438    pub fn reject(&mut self, feature: &str, task: &str, object: Object, reason: &str, by: &Who) {
439        let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
440        d.approved.retain(|g| g.object != object);
441        d.rejected.retain(|r| r.object != object);
442        d.rejected.push(Rejection {
443            object: object.clone(),
444            reason: reason.to_owned(),
445        });
446        d.decided.push(Decided {
447            verb: "reject".to_owned(),
448            object: Some(object),
449            run: None,
450            reason: Some(reason.to_owned()),
451            scope: task_scope(),
452            note: None,
453            by: by.clone(),
454            at: now(),
455        });
456    }
457
458    /// Whether `object` was decided for `feature/task` either way. An
459    /// earlier run's grant does not count: it covers nothing now.
460    #[must_use]
461    pub fn decided(&self, feature: &str, task: &str, object: &Object) -> bool {
462        let d = self.for_task(feature, task);
463        d.approved
464            .iter()
465            .any(|g| g.object == *object && g.run.is_none())
466            || d.rejected.iter().any(|r| r.object == *object)
467    }
468}
469
470// ── Inline approvals ────────────────────────────────────────────────
471
472pub mod coverage;
473pub mod derived;
474pub mod present;
475pub mod tasks;
476
477/// Calls answered Always Allow inline in this process, approved once
478/// there: the one other state `actions::perform(AllowAlways)` accepts
479/// besides a call still waiting.
480static ANSWERED_ALWAYS: std::sync::Mutex<std::collections::BTreeSet<String>> =
481    std::sync::Mutex::new(std::collections::BTreeSet::new());
482
483/// Records that the waiting call `call` was approved once by an Always
484/// Allow answer, here and now.
485pub fn answered_always(call: &str) {
486    if let Ok(mut set) = ANSWERED_ALWAYS.lock() {
487        set.insert(call.to_owned());
488    }
489}
490
491/// Whether `call` was approved by an Always Allow answer in this process;
492/// it is forgotten (one policy write per answer).
493pub(crate) fn take_answered_always(call: &str) -> bool {
494    ANSWERED_ALWAYS
495        .lock()
496        .map(|mut set| set.remove(call))
497        .unwrap_or(false)
498}
499
500pub use present::{PresentContext, Presentation, present};
501
502/// An argument of a call found verbatim in content the attempt received
503/// from a source other than the model, the person or speccoder: where it
504/// came from, so the person can tell a planted command from the model's own.
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507pub struct Source {
508    /// The matched argument, escaped, at most 120 characters.
509    pub argument: String,
510    /// The read path (`README.md`), the tool (`tool://fs/run_command`,
511    /// `tool://<server>/<tool>`), `speccoder://worktree` or
512    /// `speccoder://verifier`.
513    pub from: String,
514    /// The model turn it arrived in; 0 for the attempt's prompt.
515    pub turn: u32,
516}
517
518/// A call that needs a person: the identity of the journaled question, so
519/// only what the journal already holds goes in (the presentation — the diff,
520/// the sandbox line — is built live from it).
521#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
522pub struct Request {
523    /// `feature/task`.
524    pub task: String,
525    /// The task's title.
526    #[serde(default)]
527    pub task_title: String,
528    /// The model turn (1-based).
529    #[serde(default)]
530    pub turn: u32,
531    /// The tool.
532    pub tool: String,
533    /// Its arguments as the model sent them.
534    pub arguments: serde_json::Value,
535    /// `write`, `edit`, `run`, `network` or `mcp`.
536    #[serde(default)]
537    pub kind: String,
538    /// Why it needs approval.
539    pub reason: String,
540    /// The resolved object a decision covers; `None` only for a tool that
541    /// has none.
542    #[serde(default)]
543    pub object: Option<Object>,
544    /// What differs from an object of the same name a person approved
545    /// earlier (`executable /usr/bin/make → /w/bin/make`).
546    #[serde(default)]
547    pub changed: Option<String>,
548    /// `high` (runs arbitrary code or uses the network), `medium` (changes
549    /// dependencies, frozen tests or repository state; any MCP tool) or `low`.
550    pub risk: String,
551    /// What the model said in that turn: one line, at most 200 characters,
552    /// control characters escaped — a claim, not a fact.
553    #[serde(default)]
554    pub claim: Option<String>,
555    /// Arguments copied from content the attempt read, with where from (at
556    /// most five; `more` counts the rest).
557    #[serde(default)]
558    pub sources: Vec<Source>,
559    /// Sources beyond the five kept.
560    #[serde(default)]
561    pub more: u32,
562    /// The choices offered, in order: of `once`, `task`, `run`, `always`,
563    /// `reject`, `stop`.
564    #[serde(default)]
565    pub options: Vec<String>,
566    /// The run (`run-<secs>-<hex>`) the call belongs to.
567    #[serde(default)]
568    pub run: Option<String>,
569    /// Prompts shown in the run, this one included (0 when not prompted).
570    #[serde(default)]
571    pub asked: u32,
572    /// The run's asking budget (`[approvals] budget`).
573    #[serde(default)]
574    pub budget: u32,
575    /// The budget was used: the call waits in the run's batch, unprompted.
576    #[serde(default)]
577    pub batched: bool,
578}
579
580impl Request {
581    /// What a "for this task" answer would cover, in one line.
582    #[must_use]
583    pub fn covers(&self) -> String {
584        self.object
585            .as_ref()
586            .map_or_else(|| format!("call `{}`", self.tool), Object::describe)
587    }
588}
589
590/// How far an approval reaches.
591#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
592#[serde(rename_all = "snake_case")]
593pub enum Scope {
594    /// This call.
595    Once,
596    /// The call's class for the rest of the task.
597    Task,
598    /// The call's class in every task of this run.
599    Run,
600}
601
602impl Scope {
603    /// Its name.
604    #[must_use]
605    pub const fn name(self) -> &'static str {
606        match self {
607            Self::Once => "once",
608            Self::Task => "task",
609            Self::Run => "run",
610        }
611    }
612}
613
614/// What a person decided.
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
616#[serde(rename_all = "snake_case")]
617pub enum Decision {
618    /// Run the call.
619    Approve,
620    /// Refuse it; the model is told why.
621    Reject,
622    /// End the task here; the call waits for `speccoder task approve`.
623    Stop,
624}
625
626/// A person's answer ("later" is no answer: nothing is journaled until one exists).
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628pub struct Answer {
629    /// Approve, reject or stop.
630    pub decision: Decision,
631    /// How far an approval reaches.
632    pub scope: Scope,
633    /// An approval's note to the model.
634    #[serde(default)]
635    pub note: Option<String>,
636    /// A rejection's reason — the model is told.
637    #[serde(default)]
638    pub reason: Option<String>,
639    /// Who answered; journaled with the answer.
640    #[serde(default)]
641    pub by: Option<Who>,
642    /// "Always Allow": approved once, and the exact argv added to the
643    /// project's allow list (the runtime does that, never the interface).
644    #[serde(default)]
645    pub always: bool,
646    /// With a batch decision: start the run's asking budget again.
647    #[serde(default)]
648    pub resume_asking: bool,
649}
650
651impl Answer {
652    /// Always Allow: this call once, and exactly its argv into
653    /// `[commands] allow` (or `[sandbox] network_allow`).
654    #[must_use]
655    pub fn always(by: Option<Who>) -> Self {
656        Self {
657            always: true,
658            ..Self::approve(Scope::Once, None, by)
659        }
660    }
661
662    /// An approval with `scope` and an optional note to the model.
663    #[must_use]
664    pub fn approve(scope: Scope, note: Option<String>, by: Option<Who>) -> Self {
665        Self {
666            decision: Decision::Approve,
667            scope,
668            note: note.filter(|n| !n.trim().is_empty()),
669            reason: None,
670            by,
671            always: false,
672            resume_asking: false,
673        }
674    }
675
676    /// A rejection; the model is told `reason`.
677    #[must_use]
678    pub fn reject(reason: impl Into<String>, by: Option<Who>) -> Self {
679        Self {
680            decision: Decision::Reject,
681            scope: Scope::Once,
682            note: None,
683            reason: Some(reason.into()),
684            by,
685            always: false,
686            resume_asking: false,
687        }
688    }
689
690    /// Stop the task; the call waits.
691    #[must_use]
692    pub fn stop(by: Option<Who>) -> Self {
693        Self {
694            decision: Decision::Stop,
695            scope: Scope::Once,
696            note: None,
697            reason: None,
698            by,
699            always: false,
700            resume_asking: false,
701        }
702    }
703}
704
705/// Asks a person, synchronously, while an attempt waits (a terminal prompt
706/// in the CLI, a permission request in an editor). The answer is journaled;
707/// replay never asks again.
708pub trait Approver: Send + Sync + std::fmt::Debug {
709    /// The person's answer to the presented call.
710    fn ask(&self, p: &Presentation) -> Answer;
711
712    /// The person's free-text answer to a question a hosted skill asks;
713    /// `None` when nobody can answer.
714    fn answer(&self, question: &str) -> Option<String> {
715        let _ = question;
716        None
717    }
718
719    /// Through what the person answers.
720    fn via(&self) -> Via {
721        Via::Terminal
722    }
723
724    /// The client the person answers in (an editor's name), if it says.
725    fn client(&self) -> Option<String> {
726        None
727    }
728
729    /// The person's decision on a bug's reproductions, bound to the digest
730    /// shown; `None` when nobody can decide here (the bug waits in the
731    /// inbox for `speccoder bug confirm`).
732    fn confirm(&self, request: &ConfirmRequest) -> Option<ConfirmAnswer> {
733        let _ = request;
734        None
735    }
736}
737
738/// One reproduction as a person is shown it.
739#[derive(Debug, Clone, PartialEq, Eq)]
740pub struct ShownReproduction {
741    /// The statement it reproduces, with its words.
742    pub statement: String,
743    /// The statement's words.
744    pub words: String,
745    /// The test file.
746    pub file: String,
747    /// The test's name.
748    pub test: String,
749    /// The exact command that runs it (and only it): what the person
750    /// confirms, bound into the digest.
751    pub argv: Vec<String>,
752    /// How it failed on the untouched code (up to 20 lines).
753    pub excerpt: String,
754    /// The test's content.
755    pub content: String,
756}
757
758/// A bug's reproductions, waiting for the person's one confirmation.
759#[derive(Debug, Clone, PartialEq, Eq)]
760pub struct ConfirmRequest {
761    /// `<unit>/<task>`.
762    pub bug: String,
763    /// The bug's title.
764    pub title: String,
765    /// What a confirmation is bound to.
766    pub digest: String,
767    /// The reproductions.
768    pub reproductions: Vec<ShownReproduction>,
769    /// What the person should know before confirming (no held-out
770    /// variant could be kept: the fix cannot be verified).
771    pub warning: Option<String>,
772}
773
774/// The person's answer.
775#[derive(Debug, Clone, PartialEq, Eq)]
776pub enum ConfirmAnswer {
777    /// They show the bug: freeze them and fix it.
778    Confirm,
779    /// They do not; the reason goes to the author's next try.
780    Reject(String),
781}
782
783/// The kind of call, from the call alone (it is part of the journaled
784/// question, so it never depends on the worktree): `write` (`write_file`),
785/// `edit` (`edit_file`), `run`, `network` (a command asking for the network)
786/// or `mcp`. Whether a write replaces a file is the presentation's to say.
787#[must_use]
788pub fn kind_of(tool: &str, arguments: &serde_json::Value) -> &'static str {
789    match tool {
790        "write_file" => "write",
791        "edit_file" => "edit",
792        "run_command" if arguments["network"] == true => "network",
793        "run_command" => "run",
794        t if t.starts_with(crate::mcp::PREFIX) => "mcp",
795        _ => "edit",
796    }
797}
798
799/// The choices offered, in the fixed order `once, task, run, always,
800/// reject, stop`. Remembered options (`task`, `run`, `always`) only for a
801/// call with a resolved object whose arguments were not copied from content
802/// the attempt read (`derived`); "for this run" never for high risk;
803/// "always" only for a command (`run`, `network`) whose argv is not high
804/// risk without the network (`always_risk`: an exact allow-list entry is
805/// committed, reviewed policy).
806#[must_use]
807pub fn options(
808    risk: &str,
809    has_object: bool,
810    derived: bool,
811    kind: &str,
812    always_risk: &str,
813) -> Vec<String> {
814    let mut o = vec!["once"];
815    if has_object && !derived {
816        o.push("task");
817        if risk != "high" {
818            o.push("run");
819        }
820        if matches!(kind, "run" | "network") && always_risk != "high" {
821            o.push("always");
822        }
823    }
824    o.extend(["reject", "stop"]);
825    o.into_iter().map(str::to_owned).collect()
826}
827
828/// A model's text, one line, at most 200 characters, control characters
829/// escaped: what it claimed in the turn that asked.
830#[must_use]
831pub fn claim(text: &str) -> Option<String> {
832    let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
833    let line = present::escape(&line);
834    if line.is_empty() {
835        return None;
836    }
837    Some(if line.chars().count() > 200 {
838        format!("{}…", line.chars().take(199).collect::<String>())
839    } else {
840        line
841    })
842}
843
844/// The risk tier of a call that needs approval, from the policy's reason.
845#[must_use]
846pub fn risk(tool: &str, reason: &str) -> &'static str {
847    let r = reason.to_lowercase();
848    if tool == "run_command" && (r.contains("arbitrary") || r.contains("network")) {
849        "high"
850    } else if tool.starts_with(crate::mcp::PREFIX)
851        || r.contains("manifest")
852        || r.contains("frozen")
853        || r.contains("repository state")
854        || r.contains("recursive")
855    {
856        "medium"
857    } else {
858        "low"
859    }
860}
861
862// ── `speccoder report --approvals` / `approve` / `reject` ────────────────────────────
863
864/// The feature, its repository root and its state directory.
865fn locate(
866    command: &str,
867    path: &Path,
868    feature: Option<&str>,
869    state: &Path,
870) -> Result<(String, std::path::PathBuf, std::path::PathBuf), Box<CommandResult>> {
871    let inspected = crate::inspect(path, feature);
872    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
873        return Err(Box::new(CommandResult::carried(command, &inspected)));
874    };
875    let Some(f) = snapshot.feature.as_ref() else {
876        return Err(Box::new(CommandResult::new(
877            command,
878            CommandStatus::SelectionNeeded,
879            inspected.message(),
880        )));
881    };
882    let root = std::path::PathBuf::from(&snapshot.root);
883    Ok((
884        f.feature.name.clone(),
885        crate::worktree::project_state_dir(state, &root),
886        root,
887    ))
888}
889
890/// The object as reports show it.
891#[must_use]
892pub fn object_view(object: &Object) -> speccoder_core::ObjectView {
893    speccoder_core::ObjectView {
894        class: object.class().as_str().to_owned(),
895        short: object.short(),
896        describe: object.describe(),
897        lines: present::object_lines(Some(object), None),
898    }
899}
900
901/// The question a saved (lane) attempt stopped on: the request it
902/// recorded. An attempt saved before requests named objects has none.
903fn saved_request(feature: &str, task: &str, pending: &serde_json::Value) -> Option<Request> {
904    let mut r: Request = serde_json::from_value(pending["request"].clone()).ok()?;
905    if r.task.is_empty() {
906        r.task = format!("{feature}/{task}");
907    }
908    Some(r)
909}
910
911/// [`speccoder_core::PendingCall::digest`]: SHA-256 over the call's id
912/// and the object a decision covers, as recorded.
913#[must_use]
914pub fn call_digest(id: &str, object: Option<&Object>) -> String {
915    use sha2::{Digest as _, Sha256};
916    let object = serde_json::to_string(&object).unwrap_or_default();
917    Sha256::digest(format!("{id}\n{object}").as_bytes())
918        .iter()
919        .map(|b| format!("{b:02x}"))
920        .collect()
921}
922
923/// `p` with its digest.
924fn with_digest(mut p: PendingCall, object: Option<&Object>) -> PendingCall {
925    p.digest = call_digest(&p.id(), object);
926    p
927}
928
929/// Open tasks' latest attempts that stopped for approval and are undecided.
930fn pending(
931    root: &Path,
932    feature: &str,
933    open: &[String],
934    decisions: &Decisions,
935) -> Vec<(PendingCall, Object)> {
936    let mut seen = std::collections::BTreeSet::new();
937    let mut out = Vec::new();
938    for saved in crate::attempts::list(root, feature) {
939        if !open.contains(&saved.task) || !seen.insert(saved.task.clone()) {
940            continue; // newest first: only each open task's latest attempt
941        }
942        let Some(p) = saved.pending.filter(|_| saved.outcome == "needs_approval") else {
943            continue;
944        };
945        let Some(request) = saved_request(feature, &saved.task, &p) else {
946            continue;
947        };
948        let Some(object) = request.object.clone() else {
949            continue;
950        };
951        if decisions.decided(feature, &saved.task, &object) {
952            continue;
953        }
954        let presentation = view(root, &saved.task, &saved.reference, &request);
955        out.push((
956            with_digest(
957                PendingCall {
958                    task: saved.task.clone(),
959                    covers: object.describe(),
960                    tool: request.tool.clone(),
961                    arguments: request.arguments.clone(),
962                    reason: request.reason.clone(),
963                    reference: saved.reference.clone(),
964                    presentation: Some(presentation),
965                    source: "attempt".to_owned(),
966                    kind: "approval".to_owned(),
967                    object: Some(object_view(&object)),
968                    sources: present::source_lines(&request),
969                    run: request.run.clone(),
970                    batched: request.batched,
971                    ..PendingCall::default()
972                },
973                Some(&object),
974            ),
975            object,
976        ));
977    }
978    out
979}
980
981/// The saved call as the inline prompt would have shown it: the file as the
982/// saved attempt left it, the project's sandbox, the model's claim.
983fn view(
984    root: &Path,
985    task: &str,
986    reference: &str,
987    request: &Request,
988) -> speccoder_core::ApprovalView {
989    let path = request.arguments["path"].as_str().unwrap_or_default();
990    let file_text = (!path.is_empty())
991        .then(|| {
992            crate::git::Git::at(root)
993                .run(&["show", &format!("{reference}:./{path}")])
994                .ok()
995                .map(|b| String::from_utf8_lossy(&b).into_owned())
996        })
997        .flatten();
998    presented(root, request, task, file_text)
999}
1000
1001/// `request` as the inline prompt shows it, with the project's sandbox
1002/// and `file_text` (the file as the call would find it) for a write.
1003fn presented(
1004    root: &Path,
1005    request: &Request,
1006    task: &str,
1007    file_text: Option<String>,
1008) -> speccoder_core::ApprovalView {
1009    let config = crate::verify::find_config(root)
1010        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
1011        .unwrap_or_default();
1012    let sandbox = crate::sandbox::parse(&config).ok();
1013    let p = present(
1014        request,
1015        &PresentContext {
1016            root: None,
1017            sandbox: sandbox
1018                .as_ref()
1019                .filter(|c| c.mode != crate::sandbox::Mode::Off)
1020                .map(|_| {
1021                    if cfg!(target_os = "macos") {
1022                        "Seatbelt".to_owned()
1023                    } else {
1024                        "Landlock+seccomp".to_owned()
1025                    }
1026                }),
1027            network: match sandbox.map(|c| c.network) {
1028                Some(crate::sandbox::Network::Loopback) => "loopback",
1029                Some(crate::sandbox::Network::On) => "unrestricted",
1030                _ => "none",
1031            }
1032            .to_owned(),
1033            file_text,
1034        },
1035    );
1036    speccoder_core::ApprovalView {
1037        kind: request.kind.clone(),
1038        title: p.title,
1039        subject: p.subject,
1040        body_lines: p.body,
1041        sandbox: p.sandbox,
1042        claim: request.claim.clone(),
1043        risk: request.risk.clone(),
1044        scope: format!("for the rest of {task}: {}", p.scope),
1045        object: p.object,
1046        sources: p.sources,
1047        options: request.options.clone(),
1048    }
1049}
1050
1051fn decided(feature: &str, decisions: &Decisions) -> Vec<DecidedCall> {
1052    let prefix = format!("{feature}/");
1053    let mut out = Vec::new();
1054    for (key, d) in &decisions.tasks {
1055        let Some(task) = key.strip_prefix(&prefix) else {
1056            continue;
1057        };
1058        let last = |o: &Object| {
1059            d.decided
1060                .iter()
1061                .rev()
1062                .find(|x| x.object.as_ref() == Some(o))
1063        };
1064        out.extend(d.approved.iter().map(|g| DecidedCall {
1065            task: task.to_owned(),
1066            decision: "approved".to_owned(),
1067            covers: g.object.describe(),
1068            reason: None,
1069            by: last(&g.object).map(|x| x.by.describe()),
1070            note: last(&g.object).and_then(|x| x.note.clone()),
1071            operator: last(&g.object).map(|x| x.by.view()),
1072            via: last(&g.object).map(|x| x.by.via.clone()),
1073            client: last(&g.object).and_then(|x| x.by.client.clone()),
1074            object: Some(object_view(&g.object)),
1075            run: g.run.clone(),
1076            scope: Some(if g.once { "once" } else { "task" }.to_owned()),
1077            ..DecidedCall::default()
1078        }));
1079        out.extend(d.rejected.iter().map(|r| DecidedCall {
1080            task: task.to_owned(),
1081            decision: "rejected".to_owned(),
1082            covers: r.object.describe(),
1083            reason: Some(r.reason.clone()),
1084            by: last(&r.object).map(|x| x.by.describe()),
1085            scope: Some("task".to_owned()),
1086            note: None,
1087            operator: last(&r.object).map(|x| x.by.view()),
1088            via: last(&r.object).map(|x| x.by.via.clone()),
1089            client: last(&r.object).and_then(|x| x.by.client.clone()),
1090            object: Some(object_view(&r.object)),
1091            ..DecidedCall::default()
1092        }));
1093        // "For this run" decisions live in the history only.
1094        out.extend(
1095            d.decided
1096                .iter()
1097                .filter(|x| x.verb == "approve" && x.scope == "run")
1098                .filter_map(|x| {
1099                    let object = x.object.as_ref()?;
1100                    Some(DecidedCall {
1101                        task: task.to_owned(),
1102                        decision: "approved".to_owned(),
1103                        covers: object.describe(),
1104                        reason: None,
1105                        by: Some(x.by.describe()),
1106                        scope: Some("run".to_owned()),
1107                        note: x.note.clone(),
1108                        operator: Some(x.by.view()),
1109                        via: Some(x.by.via.clone()),
1110                        client: x.by.client.clone(),
1111                        object: Some(object_view(object)),
1112                        run: x.run.clone(),
1113                        ..DecidedCall::default()
1114                    })
1115                }),
1116        );
1117    }
1118    out
1119}
1120
1121/// Whether a call with deadline `due` still waits at `now`: one whose
1122/// deadline has passed is denied as expired by the next writer.
1123pub(crate) fn still_waits(
1124    due: Option<agentplane::core::Timestamp>,
1125    now: agentplane::core::Timestamp,
1126) -> bool {
1127    due.is_none_or(|due| due > now)
1128}
1129
1130/// A waiting call as listed, with the task row it waits on.
1131type Waiting = (PendingCall, agentplane::core::Task);
1132
1133/// Calls of `feature` waiting for a person on a human task, oldest
1134/// first: the attempt is suspended and resumes once they are decided.
1135///
1136/// Read beside the journal's writer, which may be a run in another
1137/// process. `Err` says why they could not be read: an unreadable journal
1138/// means the calls waiting are unknown, never that none wait. A project
1139/// without a journal has none, and a read never creates one. A call past
1140/// its deadline is not listed: the next writer denies it as expired (a
1141/// decision sweeps first, so it cannot be approved).
1142fn waiting_tasks(root: &Path, dir: &Path, feature: &str) -> Result<Vec<Waiting>, String> {
1143    let rt = crate::tokio_runtime()?;
1144    let Some(plane) = crate::substrate::Plane::open_for_reading(dir).map_err(|e| e.0)? else {
1145        return Ok(Vec::new());
1146    };
1147    waiting_on(&rt, &plane, root, feature)
1148}
1149
1150/// The calls of `feature` waiting on `plane`, as [`waiting_tasks`] lists them.
1151fn waiting_on(
1152    rt: &tokio::runtime::Runtime,
1153    plane: &crate::substrate::Plane,
1154    root: &Path,
1155    feature: &str,
1156) -> Result<Vec<Waiting>, String> {
1157    let open = rt
1158        .block_on(plane.open_tasks())
1159        .map_err(|e| format!("listing the waiting calls: {}", e.0))?;
1160    let now = agentplane::core::Timestamp::now_utc();
1161    let prefix = format!("{feature}/");
1162    Ok(open
1163        .into_iter()
1164        .filter(|t| t.kind == tasks::APPROVAL)
1165        .filter(|t| still_waits(t.due_at, now))
1166        .filter_map(|t| {
1167            let request = tasks::request_of(&t.justification.proposed_action)?;
1168            let task = request.task.strip_prefix(&prefix)?.to_owned();
1169            let view = presented(root, &request, &task, None);
1170            Some((
1171                with_digest(
1172                    PendingCall {
1173                        task,
1174                        covers: request.covers(),
1175                        object: request.object.as_ref().map(object_view),
1176                        sources: present::source_lines(&request),
1177                        run: request.run.clone(),
1178                        batched: request.batched,
1179                        tool: request.tool.clone(),
1180                        arguments: request.arguments.clone(),
1181                        reason: request.reason.clone(),
1182                        reference: String::new(),
1183                        presentation: Some(view),
1184                        call: t.id.to_hex(),
1185                        source: "task".to_owned(),
1186                        kind: tasks::APPROVAL.to_owned(),
1187                        question: None,
1188                        waiting_since: Some(t.created_at.unix_timestamp().max(0).unsigned_abs()),
1189                        deadline: t.due_at.map(|d| d.unix_timestamp().max(0).unsigned_abs()),
1190                        digest: String::new(),
1191                    },
1192                    request.object.as_ref(),
1193                ),
1194                t,
1195            ))
1196        })
1197        .collect())
1198}
1199
1200/// The journal at `dir` opened to decide on, through its writer: the
1201/// waiting calls are listed and decided through the one handle, so nothing
1202/// changes between the list and the decision (another writer is refused
1203/// while it is open). Opening it repairs a journal a killed run left
1204/// unclosed, which a reader cannot; every call past its deadline is denied
1205/// as expired first, so none is approved late. `None` without a journal:
1206/// nothing waits, and none is created.
1207struct Deciding {
1208    rt: tokio::runtime::Runtime,
1209    plane: crate::substrate::Plane,
1210}
1211
1212impl Deciding {
1213    /// # Errors
1214    ///
1215    /// The journal cannot be opened to write (another process writes it,
1216    /// or it cannot be read and not be replaced), or the sweep failed.
1217    fn open(dir: &Path) -> Result<Option<Self>, String> {
1218        if !dir.join(crate::substrate::JOURNAL).exists() {
1219            return Ok(None);
1220        }
1221        let rt = crate::tokio_runtime()?;
1222        let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1223        rt.block_on(plane.expire_due(agentplane::core::Timestamp::now_utc()))
1224            .map_err(|e| format!("denying the calls past their deadline: {}", e.0))?;
1225        Ok(Some(Self { rt, plane }))
1226    }
1227
1228    /// The calls of `feature` waiting now, as [`waiting_tasks`] lists them.
1229    fn waiting(&self, root: &Path, feature: &str) -> Result<Vec<Waiting>, String> {
1230        waiting_on(&self.rt, &self.plane, root, feature)
1231    }
1232
1233    /// Records `answer` on the waiting call `task`: refused when its row
1234    /// changed since it was listed, or its deadline passed since. The run
1235    /// continues when it next resumes.
1236    fn decide(
1237        &self,
1238        task: &agentplane::core::Task,
1239        answer: &Answer,
1240        by: &Who,
1241    ) -> Result<(), String> {
1242        if !still_waits(task.due_at, agentplane::core::Timestamp::now_utc()) {
1243            return Err(
1244                "its deadline passed before it was decided: it is denied as expired".to_owned(),
1245            );
1246        }
1247        let decision = tasks::decision_for(answer, by, None)
1248            .ok_or_else(|| format!("`{}` is not a name a decision can carry", by.name))?;
1249        self.rt
1250            .block_on(
1251                self.plane
1252                    .decide_at(task.id, &decision, Some(task.justification.digest())),
1253            )
1254            .map(|_| ())
1255            .map_err(|e| e.0)
1256    }
1257}
1258
1259/// Denies, as expired, every call in the journal at `dir` whose deadline
1260/// passed at `now` (nobody's decision); their attempts read the refusal
1261/// when they next resume. The path a journal this build could not read
1262/// was moved aside to, if it was.
1263///
1264/// # Errors
1265///
1266/// The journal cannot be opened to write, or the sweep failed.
1267pub fn expire_due(
1268    dir: &Path,
1269    now: agentplane::core::Timestamp,
1270) -> Result<Option<std::path::PathBuf>, String> {
1271    let rt = crate::tokio_runtime()?;
1272    let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1273    rt.block_on(plane.expire_due(now)).map_err(|e| e.0)?;
1274    Ok(plane.replaced_journal.clone())
1275}
1276
1277/// What a run of `task` finds before it starts: another task of the
1278/// feature whose attempt waits for a person, with what it waits on (its
1279/// attempt holds the feature's worktree until it is decided and resumed).
1280#[derive(Debug, Default)]
1281pub struct Elsewhere {
1282    /// The other task and what its call covers, when one waits.
1283    pub waiting: Option<(String, String)>,
1284    /// Where a journal this build could not read was moved aside to.
1285    pub replaced_journal: Option<std::path::PathBuf>,
1286}
1287
1288/// [`Elsewhere`] for a run of `task`, read through the journal's writer:
1289/// one a killed run left unclosed is repaired, not taken for one where
1290/// nothing waits.
1291///
1292/// # Errors
1293///
1294/// The waiting calls cannot be read — the run cannot tell whether another
1295/// attempt holds the worktree, so it does not start.
1296pub fn waiting_elsewhere(
1297    root: &Path,
1298    dir: &Path,
1299    feature: &str,
1300    task: &str,
1301) -> Result<Elsewhere, String> {
1302    let Some(deciding) = Deciding::open(dir)? else {
1303        return Ok(Elsewhere::default());
1304    };
1305    let waiting = deciding
1306        .waiting(root, feature)?
1307        .into_iter()
1308        .map(|(c, _)| c)
1309        .find(|c| c.task != task && c.source == "task")
1310        .map(|c| (c.task, c.covers));
1311    Ok(Elsewhere {
1312        waiting,
1313        replaced_journal: deciding.plane.replaced_journal.clone(),
1314    })
1315}
1316
1317/// Withdraws every call of `feature` waiting on a person: their attempts
1318/// were set aside (`--return`, changed intent), so nothing resumes them.
1319/// Listed and withdrawn through the journal's writer.
1320///
1321/// # Errors
1322///
1323/// The journal cannot be opened to write, or a withdrawal failed: the
1324/// calls may still wait.
1325pub fn withdraw_waiting(
1326    root: &Path,
1327    dir: &Path,
1328    feature: &str,
1329    reason: &str,
1330) -> Result<usize, String> {
1331    let Some(deciding) = Deciding::open(dir)? else {
1332        return Ok(0);
1333    };
1334    let rows = deciding.waiting(root, feature)?;
1335    if rows.is_empty() {
1336        return Ok(0);
1337    }
1338    let by = agentplane::core::Operator::from_parts(who_in(root), "asserted")
1339        .ok()
1340        .ok_or_else(|| "no name to record the withdrawal under".to_owned())?;
1341    for (_, task) in &rows {
1342        deciding
1343            .rt
1344            .block_on(deciding.plane.withdraw(task, &by, reason))
1345            .map_err(|e| e.0)?;
1346    }
1347    Ok(rows.len())
1348}
1349
1350/// A decision of several calls that failed on one: the decisions already
1351/// recorded stay recorded, so they are said first — never only an error.
1352fn partly_decided(recorded: &[String], failed: &str, of: usize) -> String {
1353    if recorded.is_empty() {
1354        return failed.to_owned();
1355    }
1356    format!(
1357        "{}\n{failed}\n{} of {of} call(s) were decided before it and stay decided; `speccoder report --approvals` lists what still waits",
1358        recorded.join("\n"),
1359        recorded.len()
1360    )
1361}
1362
1363/// What to do with a pending call.
1364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1365pub enum Verb<'a> {
1366    /// Only list.
1367    List,
1368    /// Approve the task's pending call.
1369    Approve(&'a str),
1370    /// Approve it with a note the next attempt is told.
1371    ApproveNoted(&'a str, &'a str),
1372    /// Reject the task's pending call, with a reason.
1373    Reject(&'a str, &'a str),
1374    /// Decide waiting calls as `speccoder task approve|reject` options say.
1375    Decide(Choice<'a>),
1376}
1377
1378/// Which waiting calls to decide, and how (`approve`/`reject` options).
1379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1380pub struct Choice<'a> {
1381    /// The task's calls (`--task`); all of the feature's with `--all` alone.
1382    pub task: Option<&'a str>,
1383    /// One call by its id or a prefix of it (`--call`).
1384    pub call: Option<&'a str>,
1385    /// Every matching call (`--all`), each decided and recorded on its own.
1386    pub all: bool,
1387    /// Approve, or reject.
1388    pub approve: bool,
1389    /// How far an approval reaches (`--scope`, default `task`).
1390    pub scope: Scope,
1391    /// An approval's note to the model.
1392    pub note: Option<&'a str>,
1393    /// A rejection's reason, which the model is told.
1394    pub reason: Option<&'a str>,
1395    /// The digest of the call the person was shown
1396    /// ([`speccoder_core::PendingCall::digest`]): refused when the call
1397    /// that waits now has another. `None` from the command line, which
1398    /// names the call itself.
1399    pub digest: Option<&'a str>,
1400}
1401
1402impl<'a> Choice<'a> {
1403    /// Approve the task's waiting call (for the task), with an optional note.
1404    #[must_use]
1405    pub fn approve(task: &'a str, note: Option<&'a str>) -> Self {
1406        Self {
1407            task: Some(task),
1408            call: None,
1409            all: false,
1410            approve: true,
1411            scope: Scope::Task,
1412            note,
1413            reason: None,
1414            digest: None,
1415        }
1416    }
1417
1418    /// Reject the task's waiting call; the model is told `reason`.
1419    #[must_use]
1420    pub fn reject(task: &'a str, reason: &'a str) -> Self {
1421        Self {
1422            task: Some(task),
1423            call: None,
1424            all: false,
1425            approve: false,
1426            scope: Scope::Task,
1427            note: None,
1428            reason: Some(reason),
1429            digest: None,
1430        }
1431    }
1432
1433    /// The same, bound to the call shown: its id (a waiting call's, or
1434    /// `attempt:<task>`) and digest.
1435    #[must_use]
1436    pub fn shown(self, call: &'a str, digest: &'a str) -> Self {
1437        Self {
1438            call: (!call.starts_with("attempt:")).then_some(call),
1439            digest: Some(digest),
1440            ..self
1441        }
1442    }
1443}
1444
1445/// `approvals` against `state`: the calls waiting and the decisions made.
1446pub fn list_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
1447    // Listing records nothing, so through no interface in particular.
1448    decide_with(path, feature, Verb::List, state, Via::Terminal)
1449}
1450
1451/// [`list_with`] in the default state directory.
1452#[must_use]
1453pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
1454    decide(path, feature, Verb::List, Via::Terminal)
1455}
1456
1457/// `approvals`, `approve --task` and `reject --task --reason` against
1458/// `state`, decided through `via`.
1459pub fn decide_with(
1460    path: &Path,
1461    feature: Option<&str>,
1462    verb: Verb<'_>,
1463    state: &Path,
1464    via: Via,
1465) -> CommandResult {
1466    let command = match verb {
1467        Verb::List => "approvals",
1468        Verb::Approve(_) | Verb::ApproveNoted(..) => "approve",
1469        Verb::Reject(..) => "reject",
1470        Verb::Decide(c) if c.approve => "approve",
1471        Verb::Decide(_) => "reject",
1472    };
1473    let (feature, dir, root) = match locate(command, path, feature, state) {
1474        Ok(x) => x,
1475        Err(r) => return *r,
1476    };
1477    // Decisions that cannot be read decide nothing; a listing says the
1478    // calls a lane stopped are unknown, never that none wait.
1479    let (mut decisions, decisions_unreadable) = match Decisions::load(&dir) {
1480        Ok(d) => (d, None),
1481        Err(e) if verb == Verb::List => (Decisions::default(), Some(e)),
1482        Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1483    };
1484    // A lane's stopped calls are those not decided yet: unknown without
1485    // the decisions.
1486    let lane_pending = |decisions: &Decisions, open: &[String]| {
1487        if decisions_unreadable.is_some() {
1488            Vec::new()
1489        } else {
1490            pending(&root, &feature, open, decisions)
1491        }
1492    };
1493    // Open tasks, from the speccoder branch when there is one.
1494    let status = crate::status::report(&root, Some(&feature));
1495    let open: Vec<String> = match status.data() {
1496        Some(CommandData::Report(r)) => r
1497            .tasks
1498            .iter()
1499            .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
1500            .map(|t| t.id.clone())
1501            .collect(),
1502        _ => Vec::new(),
1503    };
1504    // A call waiting on a task is decided there: who, how known, bound to
1505    // what was shown. Its attempt continues on the next run.
1506    let choice = match verb {
1507        Verb::List => None,
1508        Verb::Approve(t) => Some(Choice::approve(t, None)),
1509        Verb::ApproveNoted(t, n) => Some(Choice::approve(t, Some(n))),
1510        Verb::Reject(t, r) => Some(Choice::reject(t, r)),
1511        Verb::Decide(c) => Some(c),
1512    };
1513    // A decision lists the calls through the writer it decides with (which
1514    // repairs a journal a killed run left unclosed); a listing reads beside
1515    // whoever writes.
1516    let mut deciding = None;
1517    let listed = if choice.is_some() {
1518        Deciding::open(&dir).and_then(|d| {
1519            let rows = d
1520                .as_ref()
1521                .map_or_else(|| Ok(Vec::new()), |d| d.waiting(&root, &feature));
1522            deciding = d;
1523            rows
1524        })
1525    } else {
1526        waiting_tasks(&root, &dir, &feature)
1527    };
1528    let (tasks_waiting, unreadable) = match listed {
1529        Ok(rows) => (rows, None),
1530        Err(e) => (Vec::new(), Some(e)),
1531    };
1532    let unreadable = match (unreadable, &decisions_unreadable) {
1533        (Some(a), Some(b)) => Some(format!("{a}; {b}")),
1534        (a, b) => a.or_else(|| b.clone()),
1535    };
1536    let waiting = lane_pending(&decisions, &open);
1537
1538    if let (Some(_), Some(why)) = (choice, &unreadable) {
1539        return CommandResult::new(
1540            command,
1541            CommandStatus::Error,
1542            &format!("the calls waiting for a decision could not be read: {why}"),
1543        );
1544    }
1545    if let Some(c) = choice {
1546        let matching: Vec<&(PendingCall, agentplane::core::Task)> = tasks_waiting
1547            .iter()
1548            .filter(|(p, _)| c.task.is_none_or(|t| p.task == t))
1549            .filter(|(p, _)| {
1550                c.call
1551                    .is_none_or(|id| !id.is_empty() && p.call.starts_with(id))
1552            })
1553            .collect();
1554        if let Some(shown) = c.digest
1555            && matching.iter().any(|(p, _)| p.digest != shown)
1556        {
1557            return CommandResult::new(
1558                command,
1559                CommandStatus::UsageError,
1560                "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1561            )
1562            .with_code("CALL-CHANGED");
1563        }
1564        if matching.len() > 1 && !c.all {
1565            let list: Vec<String> = matching
1566                .iter()
1567                .map(|(p, _)| {
1568                    format!(
1569                        "{} ({}, {})",
1570                        &p.call[..12.min(p.call.len())],
1571                        p.task,
1572                        p.covers
1573                    )
1574                })
1575                .collect();
1576            return CommandResult::new(
1577                command,
1578                CommandStatus::UsageError,
1579                &format!(
1580                    "{} calls are waiting; name one with --call, or decide them all with --all: {}",
1581                    matching.len(),
1582                    list.join(", ")
1583                ),
1584            );
1585        }
1586        if let (false, Some(deciding)) = (matching.is_empty(), &deciding) {
1587            let by = Who::local_in(&root, via);
1588            let mut lines = Vec::new();
1589            for (call, row) in &matching {
1590                let answer = if c.approve {
1591                    Answer::approve(c.scope, c.note.map(str::to_owned), Some(by.clone()))
1592                } else {
1593                    Answer::reject(c.reason.unwrap_or("no reason given"), Some(by.clone()))
1594                };
1595                if let Err(e) = deciding.decide(row, &answer, &by) {
1596                    let failed = format!("not decided: {} for {}: {e}", call.covers, call.task);
1597                    return CommandResult::new(
1598                        command,
1599                        CommandStatus::Error,
1600                        &partly_decided(&lines, &failed, matching.len()),
1601                    );
1602                }
1603                lines.push(if c.approve {
1604                    format!(
1605                        "approved: {} for {} ({}) — by {} ({})",
1606                        call.covers,
1607                        call.task,
1608                        c.scope.name(),
1609                        by.name,
1610                        by.basis
1611                    )
1612                } else {
1613                    format!(
1614                        "rejected: {} for {} — by {} ({}); the model will be told: {}",
1615                        call.covers,
1616                        call.task,
1617                        by.name,
1618                        by.basis,
1619                        c.reason.unwrap_or("no reason given")
1620                    )
1621                });
1622            }
1623            // The calls waiting after the decision, or why they are unknown.
1624            let (after, unreadable) = match deciding.waiting(&root, &feature) {
1625                Ok(rows) => (rows, None),
1626                Err(e) => (Vec::new(), Some(e)),
1627            };
1628            let lanes = lane_pending(&decisions, &open);
1629            let report = ApprovalsReport {
1630                decided: decided(&feature, &decisions),
1631                pending: after
1632                    .into_iter()
1633                    .map(|(c, _)| c)
1634                    .chain(lanes.into_iter().map(|(c, _)| c))
1635                    .collect(),
1636                feature,
1637                unreadable,
1638                batch: Vec::new(),
1639            };
1640            return CommandResult::new(
1641                command,
1642                CommandStatus::Ok,
1643                &format!(
1644                    "{}; `speccoder run` continues each attempt where it stopped",
1645                    lines.join("\n")
1646                ),
1647            )
1648            .with_data(CommandData::Approvals(Box::new(report)));
1649        }
1650        if c.all || c.call.is_some() || c.task.is_none() {
1651            return CommandResult::new(
1652                command,
1653                CommandStatus::NotFound,
1654                "no call matching that awaits a decision (see `speccoder report --approvals`)",
1655            );
1656        }
1657    }
1658    // In a parallel lane, a stopped attempt is saved with its call:
1659    // deciding it records a grant of exactly that object, which the next
1660    // run that attempts the task claims (it ends with that run).
1661    let shown = match verb {
1662        Verb::Decide(c) => c.digest,
1663        _ => None,
1664    };
1665    let verb = match verb {
1666        Verb::Decide(c) => match (c.task, c.approve, c.note) {
1667            (Some(t), true, Some(n)) => Verb::ApproveNoted(t, n),
1668            (Some(t), true, None) => Verb::Approve(t),
1669            (Some(t), false, _) => Verb::Reject(t, c.reason.unwrap_or("no reason given")),
1670            (None, ..) => Verb::List,
1671        },
1672        v => v,
1673    };
1674
1675    let message = match verb {
1676        // Mapped onto the verbs above before this point.
1677        Verb::List | Verb::Decide(_) => {
1678            let n = waiting.len() + tasks_waiting.len();
1679            match (&unreadable, n) {
1680                (Some(why), 0) => format!("the calls waiting for a decision are unknown: {why}"),
1681                (Some(why), n) => {
1682                    format!("{n} call(s) await a decision; others may wait unseen: {why}")
1683                }
1684                (None, 0) => "nothing awaits a decision".to_owned(),
1685                (None, n) => format!("{n} call(s) await a decision"),
1686            }
1687        }
1688        Verb::Approve(task) | Verb::ApproveNoted(task, _) | Verb::Reject(task, _) => {
1689            let Some((call, grant)) = waiting.iter().find(|(c, _)| c.task == task) else {
1690                return CommandResult::new(
1691                    command,
1692                    CommandStatus::NotFound,
1693                    &format!(
1694                        "{task} has no call awaiting a decision (see `speccoder report --approvals`)"
1695                    ),
1696                );
1697            };
1698            if shown.is_some_and(|d| d != call.digest) {
1699                return CommandResult::new(
1700                    command,
1701                    CommandStatus::UsageError,
1702                    "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1703                )
1704                .with_code("CALL-CHANGED");
1705            }
1706            let what = match verb {
1707                Verb::Reject(_, reason) => {
1708                    decisions.reject(
1709                        &feature,
1710                        task,
1711                        grant.clone(),
1712                        reason,
1713                        &Who::local_in(&root, via),
1714                    );
1715                    format!(
1716                        "rejected: {} for {task} — the model will be told: {reason}",
1717                        call.covers
1718                    )
1719                }
1720                _ => {
1721                    decisions.approve(
1722                        &feature,
1723                        task,
1724                        grant.clone(),
1725                        None,
1726                        &Who::local_in(&root, via),
1727                    );
1728                    if let Verb::ApproveNoted(_, note) = verb
1729                        && !note.trim().is_empty()
1730                    {
1731                        let t = decisions
1732                            .tasks
1733                            .entry(format!("{feature}/{task}"))
1734                            .or_default();
1735                        if let Some(last) = t.decided.last_mut() {
1736                            last.note = Some(note.trim().to_owned());
1737                        }
1738                        // The next attempt is told, beside the approval.
1739                        t.nudges.push(format!(
1740                            "A person approved {}: {}",
1741                            call.covers,
1742                            note.trim()
1743                        ));
1744                    }
1745                    format!("approved: {} for {task}", call.covers)
1746                }
1747            };
1748            if let Err(e) = decisions.save(&dir) {
1749                return CommandResult::new(command, CommandStatus::Error, &e);
1750            }
1751            format!("{what}; run the task again to continue (speccoder run --task {task})")
1752        }
1753    };
1754    let lanes = lane_pending(&decisions, &open);
1755    let report = ApprovalsReport {
1756        decided: decided(&feature, &decisions),
1757        pending: tasks_waiting
1758            .into_iter()
1759            .map(|(c, _)| c)
1760            .chain(lanes.into_iter().map(|(c, _)| c))
1761            .collect(),
1762        feature,
1763        unreadable,
1764        batch: Vec::new(),
1765    };
1766    let report = ApprovalsReport {
1767        batch: batches(&report.pending),
1768        ..report
1769    };
1770    CommandResult::new(command, CommandStatus::Ok, &message)
1771        .with_data(CommandData::Approvals(Box::new(report)))
1772}
1773
1774// ── The asking budget's batch ───────────────────────────────────────
1775
1776/// SHA-256 over the sorted digests of the calls listed
1777/// ([`speccoder_core::PendingCall::digest`]): each binds its id and its
1778/// object, so a lane's newer attempt stopped on another object changes it.
1779#[must_use]
1780pub fn batch_digest(digests: &[String]) -> String {
1781    use sha2::{Digest as _, Sha256};
1782    let mut ids = digests.to_vec();
1783    ids.sort();
1784    Sha256::digest(ids.join("\n").as_bytes())
1785        .iter()
1786        .map(|b| format!("{b:02x}"))
1787        .collect()
1788}
1789
1790/// The runs whose asking budget is used, each with every call of it that
1791/// waits (its lanes' included), by class.
1792#[must_use]
1793pub fn batches(pending: &[PendingCall]) -> Vec<speccoder_core::BatchView> {
1794    let mut runs: Vec<String> = pending
1795        .iter()
1796        .filter(|p| p.batched)
1797        .filter_map(|p| p.run.clone())
1798        .collect();
1799    runs.sort();
1800    runs.dedup();
1801    runs.into_iter()
1802        .map(|run| {
1803            let of_run: Vec<&PendingCall> = pending
1804                .iter()
1805                .filter(|p| p.run.as_deref() == Some(run.as_str()) && p.kind == tasks::APPROVAL)
1806                .collect();
1807            let groups = ["write", "command", "network", "mcp"]
1808                .iter()
1809                .filter_map(|class| {
1810                    let calls: Vec<String> = of_run
1811                        .iter()
1812                        .filter(|p| {
1813                            p.object.as_ref().map_or("write", |o| o.class.as_str()) == *class
1814                        })
1815                        .map(|p| p.id())
1816                        .collect();
1817                    (!calls.is_empty()).then(|| speccoder_core::BatchGroup {
1818                        class: (*class).to_owned(),
1819                        calls,
1820                    })
1821                })
1822                .collect::<Vec<_>>();
1823            let digests: Vec<String> = of_run.iter().map(|p| p.digest.clone()).collect();
1824            speccoder_core::BatchView {
1825                digest: batch_digest(&digests),
1826                run,
1827                groups,
1828            }
1829        })
1830        .collect()
1831}
1832
1833/// What a batch decision decides.
1834#[derive(Debug, Clone, Copy)]
1835pub struct BatchChoice<'a> {
1836    /// The run.
1837    pub run: &'a str,
1838    /// Approve (each once) or reject.
1839    pub approve: bool,
1840    /// The calls decided: a non-empty subset of the batch.
1841    pub calls: &'a [String],
1842    /// The digest of the list the person saw.
1843    pub digest: &'a str,
1844    /// Start the run's asking budget again.
1845    pub resume_asking: bool,
1846    /// A rejection's reason, for each call.
1847    pub reason: Option<&'a str>,
1848}
1849
1850/// `DecideBatch`: decides the listed calls of a run's batch, each once —
1851/// never for the task or the run — recorded per call with who decided and
1852/// through what; refused when the list changed since the person saw it.
1853#[must_use]
1854pub fn decide_batch(
1855    path: &Path,
1856    feature: Option<&str>,
1857    choice: BatchChoice<'_>,
1858    state: Option<&Path>,
1859    via: Via,
1860) -> CommandResult {
1861    let command = "approvals";
1862    let Some(state) = state else {
1863        return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
1864            .with_code("NO-STATE-DIR");
1865    };
1866    let (feature, dir, root) = match locate(command, path, feature, state) {
1867        Ok(x) => x,
1868        Err(r) => return *r,
1869    };
1870    let listed = decide_with(path, Some(&feature), Verb::List, state, via);
1871    let Some(CommandData::Approvals(report)) = listed.data().cloned() else {
1872        return listed;
1873    };
1874    if let Some(why) = &report.unreadable {
1875        return CommandResult::new(
1876            command,
1877            CommandStatus::Error,
1878            &format!("the calls waiting for a decision could not be read: {why}"),
1879        );
1880    }
1881    let Some(batch) = report.batch.iter().find(|b| b.run == choice.run) else {
1882        return CommandResult::new(
1883            command,
1884            CommandStatus::NotFound,
1885            &format!("no calls wait together in run {}", choice.run),
1886        );
1887    };
1888    let listed_ids: Vec<String> = batch.groups.iter().flat_map(|g| g.calls.clone()).collect();
1889    if batch.digest != choice.digest {
1890        return CommandResult::new(
1891            command,
1892            CommandStatus::UsageError,
1893            &format!(
1894                "the list changed; {} calls are waiting now",
1895                listed_ids.len()
1896            ),
1897        )
1898        .with_data(CommandData::Approvals(report.clone()));
1899    }
1900    if choice.calls.is_empty() || choice.calls.iter().any(|c| !listed_ids.contains(c)) {
1901        return CommandResult::new(
1902            command,
1903            CommandStatus::UsageError,
1904            "name calls of the batch (or none to decide them all)",
1905        );
1906    }
1907    let by = Who::local_in(&root, via);
1908    let mut decisions = match Decisions::load(&dir) {
1909        Ok(d) => d,
1910        Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1911    };
1912    // Decided through the writer the calls are listed with.
1913    let deciding = match Deciding::open(&dir) {
1914        Ok(d) => d,
1915        Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
1916    };
1917    let rows = match deciding.as_ref().map(|d| d.waiting(&root, &feature)) {
1918        Some(Ok(r)) => r,
1919        None => Vec::new(),
1920        Some(Err(e)) => return CommandResult::new(command, CommandStatus::Error, &e),
1921    };
1922    let reason = choice.reason.unwrap_or("no reason given");
1923    let mut lines = Vec::new();
1924    for id in choice.calls {
1925        let mut answer = if choice.approve {
1926            Answer::approve(Scope::Once, None, Some(by.clone()))
1927        } else {
1928            Answer::reject(reason, Some(by.clone()))
1929        };
1930        answer.resume_asking = choice.resume_asking;
1931        if let Some(task) = id.strip_prefix("attempt:") {
1932            // A lane's stopped call: decided for the task's next attempt.
1933            let Some(p) = report
1934                .pending
1935                .iter()
1936                .find(|p| p.call.is_empty() && p.task == task)
1937            else {
1938                continue;
1939            };
1940            // The object the listing showed, never one found now.
1941            let object = pending(&root, &feature, &[task.to_owned()], &decisions)
1942                .into_iter()
1943                .find(|(now, _)| now.digest == p.digest)
1944                .map(|(_, o)| o);
1945            let Some(object) = object else {
1946                return CommandResult::new(
1947                    command,
1948                    CommandStatus::UsageError,
1949                    &format!("the list changed; {task}'s attempt stopped on another call"),
1950                )
1951                .with_code("CALL-CHANGED");
1952            };
1953            if choice.approve {
1954                // Once: the task's next attempt may make this call one time.
1955                decisions.approve_once(&feature, task, object, &by);
1956            } else {
1957                decisions.reject(&feature, task, object, reason, &by);
1958            }
1959            lines.push(format!("{}: {}", p.task, p.covers));
1960            continue;
1961        }
1962        let (Some((p, row)), Some(deciding)) =
1963            (rows.iter().find(|(p, _)| p.call == *id), &deciding)
1964        else {
1965            continue;
1966        };
1967        if let Err(e) = deciding.decide(row, &answer, &by) {
1968            let failed = format!("not decided: {}: {} — {e}", p.task, p.covers);
1969            let message = partly_decided(&lines, &failed, choice.calls.len());
1970            if let Err(e) = decisions.save(&dir) {
1971                return CommandResult::new(
1972                    command,
1973                    CommandStatus::Error,
1974                    &format!("{message}\n{e}"),
1975                );
1976            }
1977            return CommandResult::new(command, CommandStatus::Error, &message);
1978        }
1979        lines.push(format!("{}: {}", p.task, p.covers));
1980    }
1981    if choice.resume_asking
1982        && let Some(first) = report.pending.iter().find(|p| listed_ids.contains(&p.id()))
1983    {
1984        decisions.note(
1985            &feature,
1986            &first.task,
1987            Decided {
1988                verb: "resume_asking".to_owned(),
1989                object: None,
1990                run: Some(choice.run.to_owned()),
1991                reason: None,
1992                scope: "run".to_owned(),
1993                note: None,
1994                by: by.clone(),
1995                at: now(),
1996            },
1997        );
1998    }
1999    if let Err(e) = decisions.save(&dir) {
2000        return CommandResult::new(command, CommandStatus::Error, &e);
2001    }
2002    let after = decide_with(path, Some(&feature), Verb::List, state, via);
2003    CommandResult::new(
2004        command,
2005        CommandStatus::Ok,
2006        &format!(
2007            "{} {} call(s) once — by {} ({}){}: {}; `speccoder run` continues",
2008            if choice.approve {
2009                "approved"
2010            } else {
2011                "rejected"
2012            },
2013            lines.len(),
2014            by.name,
2015            by.via,
2016            if choice.resume_asking {
2017                "; the run asks again"
2018            } else {
2019                ""
2020            },
2021            lines.join(", ")
2022        ),
2023    )
2024    .with_data(
2025        after
2026            .data()
2027            .cloned()
2028            .unwrap_or(CommandData::Approvals(report)),
2029    )
2030}
2031
2032// ── Always Allow ────────────────────────────────────────────────────
2033
2034/// `AllowAlways`: the waiting command `call` of `task` runs once and
2035/// exactly its argv joins `[commands] allow` (or `[sandbox] network_allow`
2036/// for a network call). Only for a call that offered it; a call still
2037/// waiting is decided here, one answered Always Allow inline in this
2038/// process was decided there.
2039#[must_use]
2040pub fn allow_always(
2041    path: &Path,
2042    feature: Option<&str>,
2043    task: &str,
2044    call: &str,
2045    state: Option<&Path>,
2046    via: Via,
2047) -> CommandResult {
2048    let command = "config";
2049    let refuse = |m: &str| CommandResult::new(command, CommandStatus::UsageError, m);
2050    let Some(state) = state else {
2051        return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
2052            .with_code("NO-STATE-DIR");
2053    };
2054    let (feature, dir, root) = match locate(command, path, feature, state) {
2055        Ok(x) => x,
2056        Err(r) => return *r,
2057    };
2058    if !dir.join(crate::substrate::JOURNAL).exists() {
2059        return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2060    }
2061    let Ok(id) = agentplane::core::TaskId::parse(call) else {
2062        return refuse("not a call id");
2063    };
2064    // Read and decided through the journal's writer.
2065    let deciding = match Deciding::open(&dir) {
2066        Ok(Some(d)) => d,
2067        Ok(None) => {
2068            return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2069        }
2070        Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
2071    };
2072    let row = match deciding.rt.block_on(deciding.plane.task(id)) {
2073        Ok(Some(row)) => row,
2074        Ok(None) => {
2075            return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
2076        }
2077        Err(e) => return CommandResult::new(command, CommandStatus::Error, &e.0),
2078    };
2079    let Some(request) = tasks::request_of(&row.justification.proposed_action)
2080        .filter(|r| r.task == format!("{feature}/{task}"))
2081    else {
2082        return CommandResult::new(
2083            command,
2084            CommandStatus::NotFound,
2085            &format!("{task} has no such call"),
2086        );
2087    };
2088    let offered = matches!(request.kind.as_str(), "run" | "network")
2089        && request.options.iter().any(|o| o == "always");
2090    let Some(Object::Command { argv, network, .. }) = request.object.clone().filter(|_| offered)
2091    else {
2092        return refuse("Always Allow is not offered for this call");
2093    };
2094    let by = Who::local_in(&root, via);
2095    match row.state {
2096        agentplane::core::TaskState::Open | agentplane::core::TaskState::Claimed => {
2097            if let Err(e) = deciding.decide(&row, &Answer::always(Some(by.clone())), &by) {
2098                return CommandResult::new(command, CommandStatus::Error, &e);
2099            }
2100        }
2101        _ if take_answered_always(call) => {}
2102        _ => return refuse("this call was already decided"),
2103    }
2104    let key = if network {
2105        "sandbox.network_allow"
2106    } else {
2107        "commands.allow"
2108    };
2109    let r = crate::settings::apply_confirmed(
2110        &root,
2111        &[speccoder_core::ConfigChange {
2112            key: key.to_owned(),
2113            value: None,
2114            append: Some(serde_json::json!(argv)),
2115        }],
2116    );
2117    if r.status() != CommandStatus::Ok {
2118        return r;
2119    }
2120    let table = if network {
2121        "[sandbox] network_allow"
2122    } else {
2123        "[commands] allow"
2124    };
2125    CommandResult::new(
2126        command,
2127        CommandStatus::Ok,
2128        &format!(
2129            "allowed once; added exactly `{}` to {table} — nothing after it runs because of this (by {} via {}); commit speccoder.toml to keep it",
2130            argv.join(" "),
2131            by.name,
2132            by.via
2133        ),
2134    )
2135    .with_next(r.next().to_vec())
2136    .with_data(r.data().cloned().unwrap_or(CommandData::Config(Box::new(crate::settings::read(&root)))))
2137}
2138
2139/// [`decide_with`] in the default state directory.
2140#[must_use]
2141pub fn decide(path: &Path, feature: Option<&str>, verb: Verb<'_>, via: Via) -> CommandResult {
2142    decide_in(
2143        path,
2144        feature,
2145        verb,
2146        crate::worktree::state_dir().as_deref(),
2147        via,
2148    )
2149}
2150
2151/// [`decide_with`] in `state`, if there is one.
2152#[must_use]
2153pub fn decide_in(
2154    path: &Path,
2155    feature: Option<&str>,
2156    verb: Verb<'_>,
2157    state: Option<&Path>,
2158    via: Via,
2159) -> CommandResult {
2160    match state {
2161        Some(state) => decide_with(path, feature, verb, state, via),
2162        None => CommandResult::new(
2163            "approvals",
2164            CommandStatus::ConfigError,
2165            "no state directory (set SPECCODER_STATE_DIR or HOME)",
2166        )
2167        .with_code("NO-STATE-DIR"),
2168    }
2169}
2170
2171#[cfg(test)]
2172mod tests {
2173    use super::*;
2174
2175    fn file(path: &str) -> Object {
2176        Object::Path {
2177            canonical: path.to_owned(),
2178            asked: path.to_owned(),
2179        }
2180    }
2181
2182    #[test]
2183    fn a_failure_part_way_through_says_what_was_decided_before_it() {
2184        let recorded = vec!["approved: write `a` for T005".to_owned()];
2185        let m = partly_decided(&recorded, "not decided: write `b` for T006: gone", 2);
2186        assert!(m.starts_with("approved: write `a` for T005\n"), "{m}");
2187        assert!(m.contains("not decided: write `b` for T006: gone"), "{m}");
2188        assert!(
2189            m.contains("1 of 2 call(s) were decided before it and stay decided"),
2190            "{m}"
2191        );
2192        assert_eq!(partly_decided(&[], "not decided: x", 1), "not decided: x");
2193    }
2194
2195    #[test]
2196    fn a_call_past_its_deadline_is_not_listed_as_waiting() {
2197        let now = agentplane::core::Timestamp::now_utc();
2198        let minute = std::time::Duration::from_secs(60);
2199        assert!(still_waits(None, now));
2200        assert!(still_waits(Some(now + minute), now));
2201        assert!(!still_waits(Some(now), now));
2202        assert!(!still_waits(Some(now - minute), now));
2203    }
2204
2205    #[test]
2206    fn decisions_round_trip_and_replace_each_other() {
2207        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2208        let g = file("Cargo.toml");
2209        let mut d = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2210        let by = Who::local(Via::Terminal);
2211        d.reject("f", "T1", g.clone(), "no new dependencies", &by);
2212        d.approve("f", "T1", g.clone(), Some("run-1"), &by);
2213        assert_eq!(d.tasks["f/T1"].decided.len(), 2);
2214        assert_eq!(d.tasks["f/T1"].decided[1].by, by);
2215        d.save(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2216        let back = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2217        assert_eq!(back.for_run("f", "T1", "run-1"), std::slice::from_ref(&g));
2218        assert!(back.for_task("f", "T1").rejected.is_empty());
2219        // Another run's grant covers nothing, and is history only.
2220        assert!(back.for_run("f", "T1", "run-2").is_empty());
2221        assert!(!back.decided("f", "T1", &g) && !back.decided("f", "T2", &g));
2222    }
2223
2224    #[test]
2225    fn a_lane_decision_is_claimed_by_the_next_run_only() {
2226        let mut d = Decisions::default();
2227        let by = Who::local(Via::Terminal);
2228        d.approve("f", "T1", file("package.json"), None, &by);
2229        assert!(d.decided("f", "T1", &file("package.json")));
2230        assert!(d.for_run("f", "T1", "run-2").is_empty());
2231        assert!(d.claim("f", "T1", "run-2"));
2232        assert_eq!(d.for_run("f", "T1", "run-2"), [file("package.json")]);
2233        // Claimed once: a later run gets nothing.
2234        assert!(!d.claim("f", "T1", "run-3"));
2235        assert!(d.for_run("f", "T1", "run-3").is_empty());
2236    }
2237
2238    #[test]
2239    fn a_decisions_file_from_before_objects_is_refused_by_name() {
2240        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2241        std::fs::write(
2242            dir.path().join(FILE),
2243            r#"{"tasks":{"f/T1":{"approved":[{"kind":"write","path":"Cargo.toml"}]}}}"#,
2244        )
2245        .unwrap_or_else(|e| panic!("{e}"));
2246        let e = Decisions::load(dir.path()).err().unwrap_or_default();
2247        assert!(
2248            e.contains(FILE) && e.contains("before decisions named objects"),
2249            "{e}"
2250        );
2251    }
2252
2253    #[test]
2254    fn remembered_options_need_an_object_and_text_not_copied_from_content() {
2255        let o = |risk, has, derived, kind, always| options(risk, has, derived, kind, always);
2256        assert_eq!(
2257            o("low", true, false, "run", "low"),
2258            ["once", "task", "run", "always", "reject", "stop"]
2259        );
2260        // A network call is high risk; Always Allow is judged without it.
2261        assert_eq!(
2262            o("high", true, false, "network", "low"),
2263            ["once", "task", "always", "reject", "stop"]
2264        );
2265        assert_eq!(
2266            o("high", true, false, "run", "high"),
2267            ["once", "task", "reject", "stop"]
2268        );
2269        assert_eq!(
2270            o("low", true, false, "write", "low"),
2271            ["once", "task", "run", "reject", "stop"]
2272        );
2273        assert_eq!(
2274            o("low", true, true, "run", "low"),
2275            ["once", "reject", "stop"]
2276        );
2277        assert_eq!(
2278            o("low", false, false, "run", "low"),
2279            ["once", "reject", "stop"]
2280        );
2281    }
2282
2283    #[test]
2284    fn a_runs_batch_lists_its_lanes_stopped_calls_too() {
2285        let call = |task: &str, id: &str, batched: bool, class: &str, run: &str| {
2286            with_digest(
2287                PendingCall {
2288                    task: task.to_owned(),
2289                    call: id.to_owned(),
2290                    kind: tasks::APPROVAL.to_owned(),
2291                    run: Some(run.to_owned()),
2292                    batched,
2293                    object: Some(speccoder_core::ObjectView {
2294                        class: class.to_owned(),
2295                        ..speccoder_core::ObjectView::default()
2296                    }),
2297                    ..PendingCall::default()
2298                },
2299                Some(&file(task)),
2300            )
2301        };
2302        let pending = vec![
2303            call("T1", "aa", true, "write", "run-1"),
2304            // A lane's stop in the same run: no task id, listed by its task.
2305            call("T2", "", false, "command", "run-1"),
2306            // Another run, within its budget: no batch of its own.
2307            call("T3", "bb", false, "write", "run-2"),
2308        ];
2309        let b = batches(&pending);
2310        assert_eq!(b.len(), 1);
2311        assert_eq!(b[0].run, "run-1");
2312        let groups: Vec<(&str, Vec<String>)> = b[0]
2313            .groups
2314            .iter()
2315            .map(|g| (g.class.as_str(), g.calls.clone()))
2316            .collect();
2317        assert_eq!(
2318            groups,
2319            [
2320                ("write", vec!["aa".to_owned()]),
2321                ("command", vec!["attempt:T2".to_owned()])
2322            ]
2323        );
2324        assert_eq!(
2325            b[0].digest,
2326            batch_digest(&[pending[1].digest.clone(), pending[0].digest.clone()])
2327        );
2328        // A newer attempt of T2 stopped on another object: the same ids,
2329        // another digest, so a decision on the list seen is refused.
2330        let mut newer = pending.clone();
2331        newer[1] = with_digest(newer[1].clone(), Some(&file("other.json")));
2332        assert_eq!(newer[1].id(), pending[1].id());
2333        assert_ne!(batches(&newer)[0].digest, b[0].digest);
2334    }
2335
2336    /// A call's digest binds its id and its object: another call, or the
2337    /// same lane's attempt stopped on another object, has another.
2338    #[test]
2339    fn a_calls_digest_binds_its_id_and_object() {
2340        let a = call_digest("attempt:T1", Some(&file("a.json")));
2341        assert_eq!(a, call_digest("attempt:T1", Some(&file("a.json"))));
2342        assert_ne!(a, call_digest("attempt:T1", Some(&file("b.json"))));
2343        assert_ne!(a, call_digest("attempt:T2", Some(&file("a.json"))));
2344        assert_ne!(a, call_digest("attempt:T1", None));
2345    }
2346
2347    /// A once-grant (a lane's call decided in its batch) is claimed by the
2348    /// next run, handed to one attempt and gone after it: never a grant
2349    /// for the task or the run.
2350    #[test]
2351    fn a_once_grant_is_given_to_one_attempt_only() {
2352        let mut d = Decisions::default();
2353        let by = Who::local(Via::Terminal);
2354        d.approve_once("f", "T1", file("package.json"), &by);
2355        assert!(d.decided("f", "T1", &file("package.json")));
2356        assert!(d.claim("f", "T1", "run-2"));
2357        assert!(d.for_run("f", "T1", "run-2").is_empty(), "not for the run");
2358        assert_eq!(d.take_once("f", "T1", "run-2"), [file("package.json")]);
2359        assert!(
2360            d.take_once("f", "T1", "run-2").is_empty(),
2361            "one attempt only"
2362        );
2363        assert_eq!(d.for_task("f", "T1").decided[0].scope, "once");
2364        // It reads back as written; a task grant carries no `once`.
2365        let text = serde_json::to_string(&d).unwrap();
2366        assert_eq!(serde_json::from_str::<Decisions>(&text).unwrap(), d);
2367        d.approve("f", "T1", file("x"), Some("run-2"), &by);
2368        let grant = serde_json::to_value(&d.for_task("f", "T1").approved[0]).unwrap();
2369        assert!(grant.get("once").is_none(), "{grant}");
2370    }
2371}