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/// Calls of `feature` waiting for a person on a human task, oldest
1122/// first: the attempt is suspended and resumes once they are decided.
1123///
1124/// `Err` says why they could not be read: an unreadable journal means the
1125/// calls waiting are unknown, never that none wait. A project without a
1126/// journal has none, and a read never creates one.
1127fn waiting_tasks(
1128    root: &Path,
1129    dir: &Path,
1130    feature: &str,
1131) -> Result<Vec<(PendingCall, agentplane::core::Task)>, String> {
1132    if !dir.join(crate::substrate::JOURNAL).exists() {
1133        return Ok(Vec::new());
1134    }
1135    let rt = crate::tokio_runtime()?;
1136    let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1137    // A call past its deadline is denied as expired before it is listed.
1138    let _ = rt.block_on(plane.expire_due(agentplane::core::Timestamp::now_utc()));
1139    let open = rt
1140        .block_on(plane.open_tasks())
1141        .map_err(|e| format!("listing the waiting calls: {}", e.0))?;
1142    let prefix = format!("{feature}/");
1143    Ok(open
1144        .into_iter()
1145        .filter(|t| t.kind == tasks::APPROVAL)
1146        .filter_map(|t| {
1147            let request = tasks::request_of(&t.justification.proposed_action)?;
1148            let task = request.task.strip_prefix(&prefix)?.to_owned();
1149            let view = presented(root, &request, &task, None);
1150            Some((
1151                with_digest(
1152                    PendingCall {
1153                        task,
1154                        covers: request.covers(),
1155                        object: request.object.as_ref().map(object_view),
1156                        sources: present::source_lines(&request),
1157                        run: request.run.clone(),
1158                        batched: request.batched,
1159                        tool: request.tool.clone(),
1160                        arguments: request.arguments.clone(),
1161                        reason: request.reason.clone(),
1162                        reference: String::new(),
1163                        presentation: Some(view),
1164                        call: t.id.to_hex(),
1165                        source: "task".to_owned(),
1166                        kind: tasks::APPROVAL.to_owned(),
1167                        question: None,
1168                        waiting_since: Some(t.created_at.unix_timestamp().max(0).unsigned_abs()),
1169                        deadline: t.due_at.map(|d| d.unix_timestamp().max(0).unsigned_abs()),
1170                        digest: String::new(),
1171                    },
1172                    request.object.as_ref(),
1173                ),
1174                t,
1175            ))
1176        })
1177        .collect())
1178}
1179
1180/// Denies, as expired, every call in the journal at `dir` whose deadline
1181/// passed at `now` (nobody's decision); their attempts read the refusal
1182/// when they next resume.
1183pub fn expire_due(dir: &Path, now: agentplane::core::Timestamp) -> Result<(), String> {
1184    let rt = crate::tokio_runtime()?;
1185    let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1186    rt.block_on(plane.expire_due(now)).map_err(|e| e.0)
1187}
1188
1189/// Another task of `feature` whose attempt waits for a person, with what
1190/// it waits on: its attempt holds the feature's worktree until it is
1191/// decided and resumed.
1192#[must_use]
1193pub fn waiting_elsewhere(
1194    root: &Path,
1195    dir: &Path,
1196    feature: &str,
1197    task: &str,
1198) -> Option<(String, String)> {
1199    // Unreadable: the run that would wait opens the journal itself and
1200    // stops there with the reason, so nothing is hidden by `None` here.
1201    waiting_tasks(root, dir, feature)
1202        .ok()?
1203        .into_iter()
1204        .map(|(c, _)| c)
1205        .find(|c| c.task != task && c.source == "task")
1206        .map(|c| (c.task, c.covers))
1207}
1208
1209/// Withdraws every call of `feature` waiting on a person: their attempts
1210/// were set aside (`--return`, changed intent), so nothing resumes them.
1211pub fn withdraw_waiting(
1212    root: &Path,
1213    dir: &Path,
1214    feature: &str,
1215    reason: &str,
1216) -> Result<usize, String> {
1217    let rows = waiting_tasks(root, dir, feature)?;
1218    if rows.is_empty() {
1219        return Ok(0);
1220    }
1221    let rt = crate::tokio_runtime()?;
1222    let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1223    let by = agentplane::core::Operator::from_parts(who_in(root), "asserted")
1224        .ok()
1225        .ok_or_else(|| "no name to record the withdrawal under".to_owned())?;
1226    for (_, task) in &rows {
1227        rt.block_on(plane.withdraw(task, &by, reason))
1228            .map_err(|e| e.0)?;
1229    }
1230    Ok(rows.len())
1231}
1232
1233/// Records `answer` on the waiting call `task` through a plane without the
1234/// attempt's skill: the run continues when it next resumes.
1235fn decide_waiting(
1236    dir: &Path,
1237    task: &agentplane::core::Task,
1238    answer: &Answer,
1239    by: &Who,
1240) -> Result<(), String> {
1241    let rt = crate::tokio_runtime()?;
1242    let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
1243    let decision = tasks::decision_for(answer, by, None)
1244        .ok_or_else(|| format!("`{}` is not a name a decision can carry", by.name))?;
1245    rt.block_on(plane.decide(task.id, &decision))
1246        .map(|_| ())
1247        .map_err(|e| e.0)
1248}
1249
1250/// What to do with a pending call.
1251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1252pub enum Verb<'a> {
1253    /// Only list.
1254    List,
1255    /// Approve the task's pending call.
1256    Approve(&'a str),
1257    /// Approve it with a note the next attempt is told.
1258    ApproveNoted(&'a str, &'a str),
1259    /// Reject the task's pending call, with a reason.
1260    Reject(&'a str, &'a str),
1261    /// Decide waiting calls as `speccoder task approve|reject` options say.
1262    Decide(Choice<'a>),
1263}
1264
1265/// Which waiting calls to decide, and how (`approve`/`reject` options).
1266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1267pub struct Choice<'a> {
1268    /// The task's calls (`--task`); all of the feature's with `--all` alone.
1269    pub task: Option<&'a str>,
1270    /// One call by its id or a prefix of it (`--call`).
1271    pub call: Option<&'a str>,
1272    /// Every matching call (`--all`), each decided and recorded on its own.
1273    pub all: bool,
1274    /// Approve, or reject.
1275    pub approve: bool,
1276    /// How far an approval reaches (`--scope`, default `task`).
1277    pub scope: Scope,
1278    /// An approval's note to the model.
1279    pub note: Option<&'a str>,
1280    /// A rejection's reason, which the model is told.
1281    pub reason: Option<&'a str>,
1282    /// The digest of the call the person was shown
1283    /// ([`speccoder_core::PendingCall::digest`]): refused when the call
1284    /// that waits now has another. `None` from the command line, which
1285    /// names the call itself.
1286    pub digest: Option<&'a str>,
1287}
1288
1289impl<'a> Choice<'a> {
1290    /// Approve the task's waiting call (for the task), with an optional note.
1291    #[must_use]
1292    pub fn approve(task: &'a str, note: Option<&'a str>) -> Self {
1293        Self {
1294            task: Some(task),
1295            call: None,
1296            all: false,
1297            approve: true,
1298            scope: Scope::Task,
1299            note,
1300            reason: None,
1301            digest: None,
1302        }
1303    }
1304
1305    /// Reject the task's waiting call; the model is told `reason`.
1306    #[must_use]
1307    pub fn reject(task: &'a str, reason: &'a str) -> Self {
1308        Self {
1309            task: Some(task),
1310            call: None,
1311            all: false,
1312            approve: false,
1313            scope: Scope::Task,
1314            note: None,
1315            reason: Some(reason),
1316            digest: None,
1317        }
1318    }
1319
1320    /// The same, bound to the call shown: its id (a waiting call's, or
1321    /// `attempt:<task>`) and digest.
1322    #[must_use]
1323    pub fn shown(self, call: &'a str, digest: &'a str) -> Self {
1324        Self {
1325            call: (!call.starts_with("attempt:")).then_some(call),
1326            digest: Some(digest),
1327            ..self
1328        }
1329    }
1330}
1331
1332/// `approvals` against `state`: the calls waiting and the decisions made.
1333pub fn list_with(path: &Path, feature: Option<&str>, state: &Path) -> CommandResult {
1334    // Listing records nothing, so through no interface in particular.
1335    decide_with(path, feature, Verb::List, state, Via::Terminal)
1336}
1337
1338/// [`list_with`] in the default state directory.
1339#[must_use]
1340pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
1341    decide(path, feature, Verb::List, Via::Terminal)
1342}
1343
1344/// `approvals`, `approve --task` and `reject --task --reason` against
1345/// `state`, decided through `via`.
1346pub fn decide_with(
1347    path: &Path,
1348    feature: Option<&str>,
1349    verb: Verb<'_>,
1350    state: &Path,
1351    via: Via,
1352) -> CommandResult {
1353    let command = match verb {
1354        Verb::List => "approvals",
1355        Verb::Approve(_) | Verb::ApproveNoted(..) => "approve",
1356        Verb::Reject(..) => "reject",
1357        Verb::Decide(c) if c.approve => "approve",
1358        Verb::Decide(_) => "reject",
1359    };
1360    let (feature, dir, root) = match locate(command, path, feature, state) {
1361        Ok(x) => x,
1362        Err(r) => return *r,
1363    };
1364    // Decisions that cannot be read decide nothing; a listing says the
1365    // calls a lane stopped are unknown, never that none wait.
1366    let (mut decisions, decisions_unreadable) = match Decisions::load(&dir) {
1367        Ok(d) => (d, None),
1368        Err(e) if verb == Verb::List => (Decisions::default(), Some(e)),
1369        Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1370    };
1371    // A lane's stopped calls are those not decided yet: unknown without
1372    // the decisions.
1373    let lane_pending = |decisions: &Decisions, open: &[String]| {
1374        if decisions_unreadable.is_some() {
1375            Vec::new()
1376        } else {
1377            pending(&root, &feature, open, decisions)
1378        }
1379    };
1380    // Open tasks, from the speccoder branch when there is one.
1381    let status = crate::status::report(&root, Some(&feature));
1382    let open: Vec<String> = match status.data() {
1383        Some(CommandData::Report(r)) => r
1384            .tasks
1385            .iter()
1386            .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
1387            .map(|t| t.id.clone())
1388            .collect(),
1389        _ => Vec::new(),
1390    };
1391    let (tasks_waiting, unreadable) = match waiting_tasks(&root, &dir, &feature) {
1392        Ok(rows) => (rows, None),
1393        Err(e) => (Vec::new(), Some(e)),
1394    };
1395    let unreadable = match (unreadable, &decisions_unreadable) {
1396        (Some(a), Some(b)) => Some(format!("{a}; {b}")),
1397        (a, b) => a.or_else(|| b.clone()),
1398    };
1399    let waiting = lane_pending(&decisions, &open);
1400
1401    // A call waiting on a task is decided there: who, how known, bound to
1402    // what was shown. Its attempt continues on the next run.
1403    let choice = match verb {
1404        Verb::List => None,
1405        Verb::Approve(t) => Some(Choice::approve(t, None)),
1406        Verb::ApproveNoted(t, n) => Some(Choice::approve(t, Some(n))),
1407        Verb::Reject(t, r) => Some(Choice::reject(t, r)),
1408        Verb::Decide(c) => Some(c),
1409    };
1410    if let (Some(_), Some(why)) = (choice, &unreadable) {
1411        return CommandResult::new(
1412            command,
1413            CommandStatus::Error,
1414            &format!("the calls waiting for a decision could not be read: {why}"),
1415        );
1416    }
1417    if let Some(c) = choice {
1418        let matching: Vec<&(PendingCall, agentplane::core::Task)> = tasks_waiting
1419            .iter()
1420            .filter(|(p, _)| c.task.is_none_or(|t| p.task == t))
1421            .filter(|(p, _)| {
1422                c.call
1423                    .is_none_or(|id| !id.is_empty() && p.call.starts_with(id))
1424            })
1425            .collect();
1426        if let Some(shown) = c.digest
1427            && matching.iter().any(|(p, _)| p.digest != shown)
1428        {
1429            return CommandResult::new(
1430                command,
1431                CommandStatus::UsageError,
1432                "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1433            )
1434            .with_code("CALL-CHANGED");
1435        }
1436        if matching.len() > 1 && !c.all {
1437            let list: Vec<String> = matching
1438                .iter()
1439                .map(|(p, _)| {
1440                    format!(
1441                        "{} ({}, {})",
1442                        &p.call[..12.min(p.call.len())],
1443                        p.task,
1444                        p.covers
1445                    )
1446                })
1447                .collect();
1448            return CommandResult::new(
1449                command,
1450                CommandStatus::UsageError,
1451                &format!(
1452                    "{} calls are waiting; name one with --call, or decide them all with --all: {}",
1453                    matching.len(),
1454                    list.join(", ")
1455                ),
1456            );
1457        }
1458        if !matching.is_empty() {
1459            let by = Who::local_in(&root, via);
1460            let mut lines = Vec::new();
1461            for (call, row) in &matching {
1462                let answer = if c.approve {
1463                    Answer::approve(c.scope, c.note.map(str::to_owned), Some(by.clone()))
1464                } else {
1465                    Answer::reject(c.reason.unwrap_or("no reason given"), Some(by.clone()))
1466                };
1467                if let Err(e) = decide_waiting(&dir, row, &answer, &by) {
1468                    return CommandResult::new(command, CommandStatus::Error, &e);
1469                }
1470                lines.push(if c.approve {
1471                    format!(
1472                        "approved: {} for {} ({}) — by {} ({})",
1473                        call.covers,
1474                        call.task,
1475                        c.scope.name(),
1476                        by.name,
1477                        by.basis
1478                    )
1479                } else {
1480                    format!(
1481                        "rejected: {} for {} — by {} ({}); the model will be told: {}",
1482                        call.covers,
1483                        call.task,
1484                        by.name,
1485                        by.basis,
1486                        c.reason.unwrap_or("no reason given")
1487                    )
1488                });
1489            }
1490            // The calls waiting after the decision, or why they are unknown.
1491            let (after, unreadable) = match waiting_tasks(&root, &dir, &feature) {
1492                Ok(rows) => (rows, None),
1493                Err(e) => (Vec::new(), Some(e)),
1494            };
1495            let lanes = lane_pending(&decisions, &open);
1496            let report = ApprovalsReport {
1497                decided: decided(&feature, &decisions),
1498                pending: after
1499                    .into_iter()
1500                    .map(|(c, _)| c)
1501                    .chain(lanes.into_iter().map(|(c, _)| c))
1502                    .collect(),
1503                feature,
1504                unreadable,
1505                batch: Vec::new(),
1506            };
1507            return CommandResult::new(
1508                command,
1509                CommandStatus::Ok,
1510                &format!(
1511                    "{}; `speccoder run` continues each attempt where it stopped",
1512                    lines.join("\n")
1513                ),
1514            )
1515            .with_data(CommandData::Approvals(Box::new(report)));
1516        }
1517        if c.all || c.call.is_some() || c.task.is_none() {
1518            return CommandResult::new(
1519                command,
1520                CommandStatus::NotFound,
1521                "no call matching that awaits a decision (see `speccoder report --approvals`)",
1522            );
1523        }
1524    }
1525    // In a parallel lane, a stopped attempt is saved with its call:
1526    // deciding it records a grant of exactly that object, which the next
1527    // run that attempts the task claims (it ends with that run).
1528    let shown = match verb {
1529        Verb::Decide(c) => c.digest,
1530        _ => None,
1531    };
1532    let verb = match verb {
1533        Verb::Decide(c) => match (c.task, c.approve, c.note) {
1534            (Some(t), true, Some(n)) => Verb::ApproveNoted(t, n),
1535            (Some(t), true, None) => Verb::Approve(t),
1536            (Some(t), false, _) => Verb::Reject(t, c.reason.unwrap_or("no reason given")),
1537            (None, ..) => Verb::List,
1538        },
1539        v => v,
1540    };
1541
1542    let message = match verb {
1543        // Mapped onto the verbs above before this point.
1544        Verb::List | Verb::Decide(_) => {
1545            let n = waiting.len() + tasks_waiting.len();
1546            match (&unreadable, n) {
1547                (Some(why), 0) => format!("the calls waiting for a decision are unknown: {why}"),
1548                (Some(why), n) => {
1549                    format!("{n} call(s) await a decision; others may wait unseen: {why}")
1550                }
1551                (None, 0) => "nothing awaits a decision".to_owned(),
1552                (None, n) => format!("{n} call(s) await a decision"),
1553            }
1554        }
1555        Verb::Approve(task) | Verb::ApproveNoted(task, _) | Verb::Reject(task, _) => {
1556            let Some((call, grant)) = waiting.iter().find(|(c, _)| c.task == task) else {
1557                return CommandResult::new(
1558                    command,
1559                    CommandStatus::NotFound,
1560                    &format!(
1561                        "{task} has no call awaiting a decision (see `speccoder report --approvals`)"
1562                    ),
1563                );
1564            };
1565            if shown.is_some_and(|d| d != call.digest) {
1566                return CommandResult::new(
1567                    command,
1568                    CommandStatus::UsageError,
1569                    "the call changed since it was shown; see `speccoder report --approvals` for the one waiting now",
1570                )
1571                .with_code("CALL-CHANGED");
1572            }
1573            let what = match verb {
1574                Verb::Reject(_, reason) => {
1575                    decisions.reject(
1576                        &feature,
1577                        task,
1578                        grant.clone(),
1579                        reason,
1580                        &Who::local_in(&root, via),
1581                    );
1582                    format!(
1583                        "rejected: {} for {task} — the model will be told: {reason}",
1584                        call.covers
1585                    )
1586                }
1587                _ => {
1588                    decisions.approve(
1589                        &feature,
1590                        task,
1591                        grant.clone(),
1592                        None,
1593                        &Who::local_in(&root, via),
1594                    );
1595                    if let Verb::ApproveNoted(_, note) = verb
1596                        && !note.trim().is_empty()
1597                    {
1598                        let t = decisions
1599                            .tasks
1600                            .entry(format!("{feature}/{task}"))
1601                            .or_default();
1602                        if let Some(last) = t.decided.last_mut() {
1603                            last.note = Some(note.trim().to_owned());
1604                        }
1605                        // The next attempt is told, beside the approval.
1606                        t.nudges.push(format!(
1607                            "A person approved {}: {}",
1608                            call.covers,
1609                            note.trim()
1610                        ));
1611                    }
1612                    format!("approved: {} for {task}", call.covers)
1613                }
1614            };
1615            if let Err(e) = decisions.save(&dir) {
1616                return CommandResult::new(command, CommandStatus::Error, &e);
1617            }
1618            format!("{what}; run the task again to continue (speccoder run --task {task})")
1619        }
1620    };
1621    let lanes = lane_pending(&decisions, &open);
1622    let report = ApprovalsReport {
1623        decided: decided(&feature, &decisions),
1624        pending: tasks_waiting
1625            .into_iter()
1626            .map(|(c, _)| c)
1627            .chain(lanes.into_iter().map(|(c, _)| c))
1628            .collect(),
1629        feature,
1630        unreadable,
1631        batch: Vec::new(),
1632    };
1633    let report = ApprovalsReport {
1634        batch: batches(&report.pending),
1635        ..report
1636    };
1637    CommandResult::new(command, CommandStatus::Ok, &message)
1638        .with_data(CommandData::Approvals(Box::new(report)))
1639}
1640
1641// ── The asking budget's batch ───────────────────────────────────────
1642
1643/// SHA-256 over the sorted digests of the calls listed
1644/// ([`speccoder_core::PendingCall::digest`]): each binds its id and its
1645/// object, so a lane's newer attempt stopped on another object changes it.
1646#[must_use]
1647pub fn batch_digest(digests: &[String]) -> String {
1648    use sha2::{Digest as _, Sha256};
1649    let mut ids = digests.to_vec();
1650    ids.sort();
1651    Sha256::digest(ids.join("\n").as_bytes())
1652        .iter()
1653        .map(|b| format!("{b:02x}"))
1654        .collect()
1655}
1656
1657/// The runs whose asking budget is used, each with every call of it that
1658/// waits (its lanes' included), by class.
1659#[must_use]
1660pub fn batches(pending: &[PendingCall]) -> Vec<speccoder_core::BatchView> {
1661    let mut runs: Vec<String> = pending
1662        .iter()
1663        .filter(|p| p.batched)
1664        .filter_map(|p| p.run.clone())
1665        .collect();
1666    runs.sort();
1667    runs.dedup();
1668    runs.into_iter()
1669        .map(|run| {
1670            let of_run: Vec<&PendingCall> = pending
1671                .iter()
1672                .filter(|p| p.run.as_deref() == Some(run.as_str()) && p.kind == tasks::APPROVAL)
1673                .collect();
1674            let groups = ["write", "command", "network", "mcp"]
1675                .iter()
1676                .filter_map(|class| {
1677                    let calls: Vec<String> = of_run
1678                        .iter()
1679                        .filter(|p| {
1680                            p.object.as_ref().map_or("write", |o| o.class.as_str()) == *class
1681                        })
1682                        .map(|p| p.id())
1683                        .collect();
1684                    (!calls.is_empty()).then(|| speccoder_core::BatchGroup {
1685                        class: (*class).to_owned(),
1686                        calls,
1687                    })
1688                })
1689                .collect::<Vec<_>>();
1690            let digests: Vec<String> = of_run.iter().map(|p| p.digest.clone()).collect();
1691            speccoder_core::BatchView {
1692                digest: batch_digest(&digests),
1693                run,
1694                groups,
1695            }
1696        })
1697        .collect()
1698}
1699
1700/// What a batch decision decides.
1701#[derive(Debug, Clone, Copy)]
1702pub struct BatchChoice<'a> {
1703    /// The run.
1704    pub run: &'a str,
1705    /// Approve (each once) or reject.
1706    pub approve: bool,
1707    /// The calls decided: a non-empty subset of the batch.
1708    pub calls: &'a [String],
1709    /// The digest of the list the person saw.
1710    pub digest: &'a str,
1711    /// Start the run's asking budget again.
1712    pub resume_asking: bool,
1713    /// A rejection's reason, for each call.
1714    pub reason: Option<&'a str>,
1715}
1716
1717/// `DecideBatch`: decides the listed calls of a run's batch, each once —
1718/// never for the task or the run — recorded per call with who decided and
1719/// through what; refused when the list changed since the person saw it.
1720#[must_use]
1721pub fn decide_batch(
1722    path: &Path,
1723    feature: Option<&str>,
1724    choice: BatchChoice<'_>,
1725    state: Option<&Path>,
1726    via: Via,
1727) -> CommandResult {
1728    let command = "approvals";
1729    let Some(state) = state else {
1730        return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
1731            .with_code("NO-STATE-DIR");
1732    };
1733    let (feature, dir, root) = match locate(command, path, feature, state) {
1734        Ok(x) => x,
1735        Err(r) => return *r,
1736    };
1737    let listed = decide_with(path, Some(&feature), Verb::List, state, via);
1738    let Some(CommandData::Approvals(report)) = listed.data().cloned() else {
1739        return listed;
1740    };
1741    if let Some(why) = &report.unreadable {
1742        return CommandResult::new(
1743            command,
1744            CommandStatus::Error,
1745            &format!("the calls waiting for a decision could not be read: {why}"),
1746        );
1747    }
1748    let Some(batch) = report.batch.iter().find(|b| b.run == choice.run) else {
1749        return CommandResult::new(
1750            command,
1751            CommandStatus::NotFound,
1752            &format!("no calls wait together in run {}", choice.run),
1753        );
1754    };
1755    let listed_ids: Vec<String> = batch.groups.iter().flat_map(|g| g.calls.clone()).collect();
1756    if batch.digest != choice.digest {
1757        return CommandResult::new(
1758            command,
1759            CommandStatus::UsageError,
1760            &format!(
1761                "the list changed; {} calls are waiting now",
1762                listed_ids.len()
1763            ),
1764        )
1765        .with_data(CommandData::Approvals(report.clone()));
1766    }
1767    if choice.calls.is_empty() || choice.calls.iter().any(|c| !listed_ids.contains(c)) {
1768        return CommandResult::new(
1769            command,
1770            CommandStatus::UsageError,
1771            "name calls of the batch (or none to decide them all)",
1772        );
1773    }
1774    let by = Who::local_in(&root, via);
1775    let mut decisions = match Decisions::load(&dir) {
1776        Ok(d) => d,
1777        Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
1778    };
1779    let rows = match waiting_tasks(&root, &dir, &feature) {
1780        Ok(r) => r,
1781        Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
1782    };
1783    let reason = choice.reason.unwrap_or("no reason given");
1784    let mut lines = Vec::new();
1785    for id in choice.calls {
1786        let mut answer = if choice.approve {
1787            Answer::approve(Scope::Once, None, Some(by.clone()))
1788        } else {
1789            Answer::reject(reason, Some(by.clone()))
1790        };
1791        answer.resume_asking = choice.resume_asking;
1792        if let Some(task) = id.strip_prefix("attempt:") {
1793            // A lane's stopped call: decided for the task's next attempt.
1794            let Some(p) = report
1795                .pending
1796                .iter()
1797                .find(|p| p.call.is_empty() && p.task == task)
1798            else {
1799                continue;
1800            };
1801            // The object the listing showed, never one found now.
1802            let object = pending(&root, &feature, &[task.to_owned()], &decisions)
1803                .into_iter()
1804                .find(|(now, _)| now.digest == p.digest)
1805                .map(|(_, o)| o);
1806            let Some(object) = object else {
1807                return CommandResult::new(
1808                    command,
1809                    CommandStatus::UsageError,
1810                    &format!("the list changed; {task}'s attempt stopped on another call"),
1811                )
1812                .with_code("CALL-CHANGED");
1813            };
1814            if choice.approve {
1815                // Once: the task's next attempt may make this call one time.
1816                decisions.approve_once(&feature, task, object, &by);
1817            } else {
1818                decisions.reject(&feature, task, object, reason, &by);
1819            }
1820            lines.push(format!("{}: {}", p.task, p.covers));
1821            continue;
1822        }
1823        let Some((p, row)) = rows.iter().find(|(p, _)| p.call == *id) else {
1824            continue;
1825        };
1826        if let Err(e) = decide_waiting(&dir, row, &answer, &by) {
1827            return CommandResult::new(command, CommandStatus::Error, &e);
1828        }
1829        lines.push(format!("{}: {}", p.task, p.covers));
1830    }
1831    if choice.resume_asking
1832        && let Some(first) = report.pending.iter().find(|p| listed_ids.contains(&p.id()))
1833    {
1834        decisions.note(
1835            &feature,
1836            &first.task,
1837            Decided {
1838                verb: "resume_asking".to_owned(),
1839                object: None,
1840                run: Some(choice.run.to_owned()),
1841                reason: None,
1842                scope: "run".to_owned(),
1843                note: None,
1844                by: by.clone(),
1845                at: now(),
1846            },
1847        );
1848    }
1849    if let Err(e) = decisions.save(&dir) {
1850        return CommandResult::new(command, CommandStatus::Error, &e);
1851    }
1852    let after = decide_with(path, Some(&feature), Verb::List, state, via);
1853    CommandResult::new(
1854        command,
1855        CommandStatus::Ok,
1856        &format!(
1857            "{} {} call(s) once — by {} ({}){}: {}; `speccoder run` continues",
1858            if choice.approve {
1859                "approved"
1860            } else {
1861                "rejected"
1862            },
1863            lines.len(),
1864            by.name,
1865            by.via,
1866            if choice.resume_asking {
1867                "; the run asks again"
1868            } else {
1869                ""
1870            },
1871            lines.join(", ")
1872        ),
1873    )
1874    .with_data(
1875        after
1876            .data()
1877            .cloned()
1878            .unwrap_or(CommandData::Approvals(report)),
1879    )
1880}
1881
1882// ── Always Allow ────────────────────────────────────────────────────
1883
1884/// `AllowAlways`: the waiting command `call` of `task` runs once and
1885/// exactly its argv joins `[commands] allow` (or `[sandbox] network_allow`
1886/// for a network call). Only for a call that offered it; a call still
1887/// waiting is decided here, one answered Always Allow inline in this
1888/// process was decided there.
1889#[must_use]
1890pub fn allow_always(
1891    path: &Path,
1892    feature: Option<&str>,
1893    task: &str,
1894    call: &str,
1895    state: Option<&Path>,
1896    via: Via,
1897) -> CommandResult {
1898    let command = "config";
1899    let refuse = |m: &str| CommandResult::new(command, CommandStatus::UsageError, m);
1900    let Some(state) = state else {
1901        return CommandResult::new(command, CommandStatus::ConfigError, "no state directory")
1902            .with_code("NO-STATE-DIR");
1903    };
1904    let (feature, dir, root) = match locate(command, path, feature, state) {
1905        Ok(x) => x,
1906        Err(r) => return *r,
1907    };
1908    if !dir.join(crate::substrate::JOURNAL).exists() {
1909        return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
1910    }
1911    let Ok(id) = agentplane::core::TaskId::parse(call) else {
1912        return refuse("not a call id");
1913    };
1914    let row = match crate::tokio_runtime().and_then(|rt| {
1915        let plane = crate::substrate::Plane::open_for_decisions(&dir).map_err(|e| e.0)?;
1916        rt.block_on(plane.task(id)).map_err(|e| e.0)
1917    }) {
1918        Ok(Some(row)) => row,
1919        Ok(None) => {
1920            return CommandResult::new(command, CommandStatus::NotFound, "no such waiting call");
1921        }
1922        Err(e) => return CommandResult::new(command, CommandStatus::Error, &e),
1923    };
1924    let Some(request) = tasks::request_of(&row.justification.proposed_action)
1925        .filter(|r| r.task == format!("{feature}/{task}"))
1926    else {
1927        return CommandResult::new(
1928            command,
1929            CommandStatus::NotFound,
1930            &format!("{task} has no such call"),
1931        );
1932    };
1933    let offered = matches!(request.kind.as_str(), "run" | "network")
1934        && request.options.iter().any(|o| o == "always");
1935    let Some(Object::Command { argv, network, .. }) = request.object.clone().filter(|_| offered)
1936    else {
1937        return refuse("Always Allow is not offered for this call");
1938    };
1939    let by = Who::local_in(&root, via);
1940    match row.state {
1941        agentplane::core::TaskState::Open | agentplane::core::TaskState::Claimed => {
1942            if let Err(e) = decide_waiting(&dir, &row, &Answer::always(Some(by.clone())), &by) {
1943                return CommandResult::new(command, CommandStatus::Error, &e);
1944            }
1945        }
1946        _ if take_answered_always(call) => {}
1947        _ => return refuse("this call was already decided"),
1948    }
1949    let key = if network {
1950        "sandbox.network_allow"
1951    } else {
1952        "commands.allow"
1953    };
1954    let r = crate::settings::apply_confirmed(
1955        &root,
1956        &[speccoder_core::ConfigChange {
1957            key: key.to_owned(),
1958            value: None,
1959            append: Some(serde_json::json!(argv)),
1960        }],
1961    );
1962    if r.status() != CommandStatus::Ok {
1963        return r;
1964    }
1965    let table = if network {
1966        "[sandbox] network_allow"
1967    } else {
1968        "[commands] allow"
1969    };
1970    CommandResult::new(
1971        command,
1972        CommandStatus::Ok,
1973        &format!(
1974            "allowed once; added exactly `{}` to {table} — nothing after it runs because of this (by {} via {}); commit speccoder.toml to keep it",
1975            argv.join(" "),
1976            by.name,
1977            by.via
1978        ),
1979    )
1980    .with_next(r.next().to_vec())
1981    .with_data(r.data().cloned().unwrap_or(CommandData::Config(Box::new(crate::settings::read(&root)))))
1982}
1983
1984/// [`decide_with`] in the default state directory.
1985#[must_use]
1986pub fn decide(path: &Path, feature: Option<&str>, verb: Verb<'_>, via: Via) -> CommandResult {
1987    decide_in(
1988        path,
1989        feature,
1990        verb,
1991        crate::worktree::state_dir().as_deref(),
1992        via,
1993    )
1994}
1995
1996/// [`decide_with`] in `state`, if there is one.
1997#[must_use]
1998pub fn decide_in(
1999    path: &Path,
2000    feature: Option<&str>,
2001    verb: Verb<'_>,
2002    state: Option<&Path>,
2003    via: Via,
2004) -> CommandResult {
2005    match state {
2006        Some(state) => decide_with(path, feature, verb, state, via),
2007        None => CommandResult::new(
2008            "approvals",
2009            CommandStatus::ConfigError,
2010            "no state directory (set SPECCODER_STATE_DIR or HOME)",
2011        )
2012        .with_code("NO-STATE-DIR"),
2013    }
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018    use super::*;
2019
2020    fn file(path: &str) -> Object {
2021        Object::Path {
2022            canonical: path.to_owned(),
2023            asked: path.to_owned(),
2024        }
2025    }
2026
2027    #[test]
2028    fn decisions_round_trip_and_replace_each_other() {
2029        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2030        let g = file("Cargo.toml");
2031        let mut d = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2032        let by = Who::local(Via::Terminal);
2033        d.reject("f", "T1", g.clone(), "no new dependencies", &by);
2034        d.approve("f", "T1", g.clone(), Some("run-1"), &by);
2035        assert_eq!(d.tasks["f/T1"].decided.len(), 2);
2036        assert_eq!(d.tasks["f/T1"].decided[1].by, by);
2037        d.save(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2038        let back = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
2039        assert_eq!(back.for_run("f", "T1", "run-1"), std::slice::from_ref(&g));
2040        assert!(back.for_task("f", "T1").rejected.is_empty());
2041        // Another run's grant covers nothing, and is history only.
2042        assert!(back.for_run("f", "T1", "run-2").is_empty());
2043        assert!(!back.decided("f", "T1", &g) && !back.decided("f", "T2", &g));
2044    }
2045
2046    #[test]
2047    fn a_lane_decision_is_claimed_by_the_next_run_only() {
2048        let mut d = Decisions::default();
2049        let by = Who::local(Via::Terminal);
2050        d.approve("f", "T1", file("package.json"), None, &by);
2051        assert!(d.decided("f", "T1", &file("package.json")));
2052        assert!(d.for_run("f", "T1", "run-2").is_empty());
2053        assert!(d.claim("f", "T1", "run-2"));
2054        assert_eq!(d.for_run("f", "T1", "run-2"), [file("package.json")]);
2055        // Claimed once: a later run gets nothing.
2056        assert!(!d.claim("f", "T1", "run-3"));
2057        assert!(d.for_run("f", "T1", "run-3").is_empty());
2058    }
2059
2060    #[test]
2061    fn a_decisions_file_from_before_objects_is_refused_by_name() {
2062        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2063        std::fs::write(
2064            dir.path().join(FILE),
2065            r#"{"tasks":{"f/T1":{"approved":[{"kind":"write","path":"Cargo.toml"}]}}}"#,
2066        )
2067        .unwrap_or_else(|e| panic!("{e}"));
2068        let e = Decisions::load(dir.path()).err().unwrap_or_default();
2069        assert!(
2070            e.contains(FILE) && e.contains("before decisions named objects"),
2071            "{e}"
2072        );
2073    }
2074
2075    #[test]
2076    fn remembered_options_need_an_object_and_text_not_copied_from_content() {
2077        let o = |risk, has, derived, kind, always| options(risk, has, derived, kind, always);
2078        assert_eq!(
2079            o("low", true, false, "run", "low"),
2080            ["once", "task", "run", "always", "reject", "stop"]
2081        );
2082        // A network call is high risk; Always Allow is judged without it.
2083        assert_eq!(
2084            o("high", true, false, "network", "low"),
2085            ["once", "task", "always", "reject", "stop"]
2086        );
2087        assert_eq!(
2088            o("high", true, false, "run", "high"),
2089            ["once", "task", "reject", "stop"]
2090        );
2091        assert_eq!(
2092            o("low", true, false, "write", "low"),
2093            ["once", "task", "run", "reject", "stop"]
2094        );
2095        assert_eq!(
2096            o("low", true, true, "run", "low"),
2097            ["once", "reject", "stop"]
2098        );
2099        assert_eq!(
2100            o("low", false, false, "run", "low"),
2101            ["once", "reject", "stop"]
2102        );
2103    }
2104
2105    #[test]
2106    fn a_runs_batch_lists_its_lanes_stopped_calls_too() {
2107        let call = |task: &str, id: &str, batched: bool, class: &str, run: &str| {
2108            with_digest(
2109                PendingCall {
2110                    task: task.to_owned(),
2111                    call: id.to_owned(),
2112                    kind: tasks::APPROVAL.to_owned(),
2113                    run: Some(run.to_owned()),
2114                    batched,
2115                    object: Some(speccoder_core::ObjectView {
2116                        class: class.to_owned(),
2117                        ..speccoder_core::ObjectView::default()
2118                    }),
2119                    ..PendingCall::default()
2120                },
2121                Some(&file(task)),
2122            )
2123        };
2124        let pending = vec![
2125            call("T1", "aa", true, "write", "run-1"),
2126            // A lane's stop in the same run: no task id, listed by its task.
2127            call("T2", "", false, "command", "run-1"),
2128            // Another run, within its budget: no batch of its own.
2129            call("T3", "bb", false, "write", "run-2"),
2130        ];
2131        let b = batches(&pending);
2132        assert_eq!(b.len(), 1);
2133        assert_eq!(b[0].run, "run-1");
2134        let groups: Vec<(&str, Vec<String>)> = b[0]
2135            .groups
2136            .iter()
2137            .map(|g| (g.class.as_str(), g.calls.clone()))
2138            .collect();
2139        assert_eq!(
2140            groups,
2141            [
2142                ("write", vec!["aa".to_owned()]),
2143                ("command", vec!["attempt:T2".to_owned()])
2144            ]
2145        );
2146        assert_eq!(
2147            b[0].digest,
2148            batch_digest(&[pending[1].digest.clone(), pending[0].digest.clone()])
2149        );
2150        // A newer attempt of T2 stopped on another object: the same ids,
2151        // another digest, so a decision on the list seen is refused.
2152        let mut newer = pending.clone();
2153        newer[1] = with_digest(newer[1].clone(), Some(&file("other.json")));
2154        assert_eq!(newer[1].id(), pending[1].id());
2155        assert_ne!(batches(&newer)[0].digest, b[0].digest);
2156    }
2157
2158    /// A call's digest binds its id and its object: another call, or the
2159    /// same lane's attempt stopped on another object, has another.
2160    #[test]
2161    fn a_calls_digest_binds_its_id_and_object() {
2162        let a = call_digest("attempt:T1", Some(&file("a.json")));
2163        assert_eq!(a, call_digest("attempt:T1", Some(&file("a.json"))));
2164        assert_ne!(a, call_digest("attempt:T1", Some(&file("b.json"))));
2165        assert_ne!(a, call_digest("attempt:T2", Some(&file("a.json"))));
2166        assert_ne!(a, call_digest("attempt:T1", None));
2167    }
2168
2169    /// A once-grant (a lane's call decided in its batch) is claimed by the
2170    /// next run, handed to one attempt and gone after it: never a grant
2171    /// for the task or the run.
2172    #[test]
2173    fn a_once_grant_is_given_to_one_attempt_only() {
2174        let mut d = Decisions::default();
2175        let by = Who::local(Via::Terminal);
2176        d.approve_once("f", "T1", file("package.json"), &by);
2177        assert!(d.decided("f", "T1", &file("package.json")));
2178        assert!(d.claim("f", "T1", "run-2"));
2179        assert!(d.for_run("f", "T1", "run-2").is_empty(), "not for the run");
2180        assert_eq!(d.take_once("f", "T1", "run-2"), [file("package.json")]);
2181        assert!(
2182            d.take_once("f", "T1", "run-2").is_empty(),
2183            "one attempt only"
2184        );
2185        assert_eq!(d.for_task("f", "T1").decided[0].scope, "once");
2186        // It reads back as written; a task grant carries no `once`.
2187        let text = serde_json::to_string(&d).unwrap();
2188        assert_eq!(serde_json::from_str::<Decisions>(&text).unwrap(), d);
2189        d.approve("f", "T1", file("x"), Some("run-2"), &by);
2190        let grant = serde_json::to_value(&d.for_task("f", "T1").approved[0]).unwrap();
2191        assert!(grant.get("once").is_none(), "{grant}");
2192    }
2193}