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 class — the path
5//! of a write, the exact argv of a command — into an approval for the task;
6//! `speccoder task reject` into a denial with a reason the model is told. The
7//! next attempt carries the task's decisions in its journaled input, so
8//! strict replay needs nothing else. Standing approvals for commands are
9//! `[commands] allow` in `speccoder.toml`.
10
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use serde::{Deserialize, Serialize};
15use speccoder_core::{
16    ApprovalsReport, CommandData, CommandResult, CommandStatus, DecidedCall, PendingCall,
17};
18
19use crate::tools::policy::{Grant, Rejection};
20
21/// The decisions file in a project's state directory.
22pub const FILE: &str = "approvals.json";
23
24/// One task's decisions.
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct TaskDecisions {
27    /// Approved classes.
28    #[serde(default)]
29    pub approved: Vec<Grant>,
30    /// Rejected classes with reasons.
31    #[serde(default)]
32    pub rejected: Vec<Rejection>,
33    /// A person's notes for the task's next attempts (`speccoder task nudge`).
34    #[serde(default)]
35    pub nudges: Vec<String>,
36    /// Every decision, in order, with who made it.
37    #[serde(default)]
38    pub decided: Vec<Decided>,
39}
40
41/// Who decided, through what, and how speccoder knows the name.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Who {
44    /// The person (see [`who`]).
45    pub name: String,
46    /// `terminal`, `editor <name>` or `cli`.
47    pub via: String,
48    /// `asserted` (typed at a local terminal or the CLI) or `connected`
49    /// (reported by a client over its connection). speccoder authenticates
50    /// nobody, so never `authenticated`.
51    #[serde(default = "asserted")]
52    pub basis: String,
53}
54
55fn asserted() -> String {
56    "asserted".to_owned()
57}
58
59impl Who {
60    /// The local person, deciding through `via`: over an editor's
61    /// connection the name is the connection's (`connected`), otherwise
62    /// the person's own (`asserted`).
63    #[must_use]
64    pub fn local(via: &str) -> Self {
65        Self::local_in(Path::new("."), via)
66    }
67
68    /// [`Who::local`], named as git names the person in `dir` (the project).
69    #[must_use]
70    pub fn local_in(dir: &Path, via: &str) -> Self {
71        Self {
72            name: who_in(dir),
73            via: via.to_owned(),
74            basis: if via.starts_with("editor") {
75                "connected"
76            } else {
77                "asserted"
78            }
79            .to_owned(),
80        }
81    }
82
83    /// One line: `hupe (terminal)`.
84    #[must_use]
85    pub fn describe(&self) -> String {
86        format!("{} ({})", self.name, self.via)
87    }
88
89    /// agentplane's operator: the name and its basis.
90    #[must_use]
91    pub fn operator(&self) -> Option<agentplane::core::Operator> {
92        agentplane::core::Operator::from_parts(&self.name, &self.basis).ok()
93    }
94
95    /// The operator as reports show it.
96    #[must_use]
97    pub fn view(&self) -> speccoder_core::OperatorView {
98        speccoder_core::OperatorView {
99            actor: self.name.clone(),
100            basis: self.basis.clone(),
101        }
102    }
103}
104
105/// The local person's name, the same for every decision and attestation:
106/// git's `user.name` (what their commits say), else the operating-system
107/// user, else `unknown` — never a guess.
108#[must_use]
109pub fn who() -> String {
110    who_in(Path::new("."))
111}
112
113/// [`who`], reading git's configuration as it applies in `dir`.
114#[must_use]
115pub fn who_in(dir: &Path) -> String {
116    crate::git::Git::at(dir)
117        .line(&["config", "user.name"])
118        .ok()
119        .filter(|n| !n.trim().is_empty())
120        .or_else(|| {
121            ["USER", "USERNAME", "LOGNAME"]
122                .iter()
123                .find_map(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty()))
124        })
125        .unwrap_or_else(|| "unknown".to_owned())
126}
127
128/// One decision, as it was made.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct Decided {
131    /// `approve` or `reject`.
132    pub verb: String,
133    /// The call class.
134    pub grant: Grant,
135    /// A rejection's reason.
136    #[serde(default)]
137    pub reason: Option<String>,
138    /// How far an approval reaches: `once`, `task` or `run` (a rejection
139    /// holds for the task).
140    #[serde(default = "task_scope")]
141    pub scope: String,
142    /// An approval's note to the model.
143    #[serde(default)]
144    pub note: Option<String>,
145    /// Who decided.
146    pub by: Who,
147    /// Seconds since the epoch.
148    pub at: u64,
149}
150
151fn task_scope() -> String {
152    "task".to_owned()
153}
154
155fn now() -> u64 {
156    std::time::SystemTime::now()
157        .duration_since(std::time::UNIX_EPOCH)
158        .map_or(0, |d| d.as_secs())
159}
160
161/// All decisions, by `feature/task`.
162#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
163pub struct Decisions {
164    /// By `feature/task`.
165    #[serde(default)]
166    pub tasks: BTreeMap<String, TaskDecisions>,
167}
168
169impl Decisions {
170    /// Reads the decisions in `dir`; none when there is no file yet.
171    pub fn load(dir: &Path) -> Result<Self, String> {
172        match std::fs::read_to_string(dir.join(FILE)) {
173            Ok(text) => serde_json::from_str(&text)
174                .map_err(|e| format!("{}: {e}", dir.join(FILE).display())),
175            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
176            Err(e) => Err(format!("{}: {e}", dir.join(FILE).display())),
177        }
178    }
179
180    /// Writes the decisions to `dir` (temporary file, then rename).
181    pub fn save(&self, dir: &Path) -> Result<(), String> {
182        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
183        let text = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
184        let tmp = dir.join(format!("{FILE}.tmp"));
185        std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
186        std::fs::rename(&tmp, dir.join(FILE)).map_err(|e| format!("{}: {e}", dir.display()))
187    }
188
189    /// The decisions for `feature/task`.
190    #[must_use]
191    pub fn for_task(&self, feature: &str, task: &str) -> TaskDecisions {
192        self.tasks
193            .get(&format!("{feature}/{task}"))
194            .cloned()
195            .unwrap_or_default()
196    }
197
198    /// Records an approval; a rejection of the same class is withdrawn.
199    pub fn approve(&mut self, feature: &str, task: &str, grant: Grant, by: &Who) {
200        let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
201        d.rejected.retain(|r| r.grant != grant);
202        if !d.approved.contains(&grant) {
203            d.approved.push(grant.clone());
204        }
205        d.decided.push(Decided {
206            verb: "approve".to_owned(),
207            grant,
208            reason: None,
209            scope: task_scope(),
210            note: None,
211            by: by.clone(),
212            at: now(),
213        });
214    }
215
216    /// Records a decision that changes nothing for the task (an inline
217    /// approval or rejection of one call): the history only.
218    pub fn note(&mut self, feature: &str, task: &str, decided: Decided) {
219        self.tasks
220            .entry(format!("{feature}/{task}"))
221            .or_default()
222            .decided
223            .push(decided);
224    }
225
226    /// Records a rejection; an approval of the same class is withdrawn.
227    pub fn reject(&mut self, feature: &str, task: &str, grant: Grant, reason: &str, by: &Who) {
228        let d = self.tasks.entry(format!("{feature}/{task}")).or_default();
229        d.approved.retain(|g| *g != grant);
230        d.rejected.retain(|r| r.grant != grant);
231        d.rejected.push(Rejection {
232            grant: grant.clone(),
233            reason: reason.to_owned(),
234        });
235        d.decided.push(Decided {
236            verb: "reject".to_owned(),
237            grant,
238            reason: Some(reason.to_owned()),
239            scope: task_scope(),
240            note: None,
241            by: by.clone(),
242            at: now(),
243        });
244    }
245
246    /// Whether `grant` was decided for `feature/task` either way.
247    #[must_use]
248    pub fn decided(&self, feature: &str, task: &str, grant: &Grant) -> bool {
249        let d = self.for_task(feature, task);
250        d.approved.contains(grant) || d.rejected.iter().any(|r| &r.grant == grant)
251    }
252}
253
254// ── Inline approvals ────────────────────────────────────────────────
255
256pub mod present;
257pub mod tasks;
258
259pub use present::{PresentContext, Presentation, present};
260
261/// A call that needs a person: the identity of the journaled question, so
262/// only what the journal already holds goes in (the presentation — the diff,
263/// the sandbox line — is built live from it).
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct Request {
266    /// `feature/task`.
267    pub task: String,
268    /// The task's title.
269    #[serde(default)]
270    pub task_title: String,
271    /// The model turn (1-based).
272    #[serde(default)]
273    pub turn: u32,
274    /// The tool.
275    pub tool: String,
276    /// Its arguments as the model sent them.
277    pub arguments: serde_json::Value,
278    /// `write`, `edit`, `run`, `network` or `mcp`.
279    #[serde(default)]
280    pub kind: String,
281    /// Why it needs approval.
282    pub reason: String,
283    /// What a "for this task" answer would cover.
284    pub covers: String,
285    /// `high` (runs arbitrary code or uses the network), `medium` (changes
286    /// dependencies, frozen tests or repository state; any MCP tool) or `low`.
287    pub risk: String,
288    /// What the model said in that turn: one line, at most 200 characters,
289    /// control characters escaped — a claim, not a fact.
290    #[serde(default)]
291    pub claim: Option<String>,
292    /// The choices offered, in order: of `once`, `task`, `run`, `reject`, `stop`.
293    #[serde(default)]
294    pub options: Vec<String>,
295}
296
297/// How far an approval reaches.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case")]
300pub enum Scope {
301    /// This call.
302    Once,
303    /// The call's class for the rest of the task.
304    Task,
305    /// The call's class in every task of this run.
306    Run,
307}
308
309impl Scope {
310    /// Its name.
311    #[must_use]
312    pub const fn name(self) -> &'static str {
313        match self {
314            Self::Once => "once",
315            Self::Task => "task",
316            Self::Run => "run",
317        }
318    }
319}
320
321/// What a person decided.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum Decision {
325    /// Run the call.
326    Approve,
327    /// Refuse it; the model is told why.
328    Reject,
329    /// End the task here; the call waits for `speccoder task approve`.
330    Stop,
331}
332
333/// A person's answer ("later" is no answer: nothing is journaled until one exists).
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct Answer {
336    /// Approve, reject or stop.
337    pub decision: Decision,
338    /// How far an approval reaches.
339    pub scope: Scope,
340    /// An approval's note to the model.
341    #[serde(default)]
342    pub note: Option<String>,
343    /// A rejection's reason — the model is told.
344    #[serde(default)]
345    pub reason: Option<String>,
346    /// Who answered; journaled with the answer.
347    #[serde(default)]
348    pub by: Option<Who>,
349}
350
351impl Answer {
352    /// An approval with `scope` and an optional note to the model.
353    #[must_use]
354    pub fn approve(scope: Scope, note: Option<String>, by: Option<Who>) -> Self {
355        Self {
356            decision: Decision::Approve,
357            scope,
358            note: note.filter(|n| !n.trim().is_empty()),
359            reason: None,
360            by,
361        }
362    }
363
364    /// A rejection; the model is told `reason`.
365    #[must_use]
366    pub fn reject(reason: impl Into<String>, by: Option<Who>) -> Self {
367        Self {
368            decision: Decision::Reject,
369            scope: Scope::Once,
370            note: None,
371            reason: Some(reason.into()),
372            by,
373        }
374    }
375
376    /// Stop the task; the call waits.
377    #[must_use]
378    pub fn stop(by: Option<Who>) -> Self {
379        Self {
380            decision: Decision::Stop,
381            scope: Scope::Once,
382            note: None,
383            reason: None,
384            by,
385        }
386    }
387}
388
389/// Asks a person, synchronously, while an attempt waits (a terminal prompt
390/// in the CLI, a permission request in an editor). The answer is journaled;
391/// replay never asks again.
392pub trait Approver: Send + Sync + std::fmt::Debug {
393    /// The person's answer to the presented call.
394    fn ask(&self, p: &Presentation) -> Answer;
395
396    /// The person's free-text answer to a question a hosted skill asks
397    ///; `None` when nobody can answer.
398    fn answer(&self, question: &str) -> Option<String> {
399        let _ = question;
400        None
401    }
402
403    /// Through what the person answers: `terminal`, or `editor <name>`.
404    fn via(&self) -> String {
405        "terminal".to_owned()
406    }
407}
408
409/// The kind of call, from the call alone (it is part of the journaled
410/// question, so it never depends on the worktree): `write` (`write_file`),
411/// `edit` (`edit_file`), `run`, `network` (a command asking for the network)
412/// or `mcp`. Whether a write replaces a file is the presentation's to say.
413#[must_use]
414pub fn kind_of(tool: &str, arguments: &serde_json::Value) -> &'static str {
415    match tool {
416        "write_file" => "write",
417        "edit_file" => "edit",
418        "run_command" if arguments["network"] == true => "network",
419        "run_command" => "run",
420        t if t.starts_with(crate::mcp::PREFIX) => "mcp",
421        _ => "edit",
422    }
423}
424
425/// The choices offered: "for this run" never for high risk; neither "task"
426/// nor "run" when the class is not shown in full.
427#[must_use]
428pub fn options(risk: &str, subject_complete: bool) -> Vec<String> {
429    let mut o = vec!["once"];
430    if subject_complete {
431        o.push("task");
432        if risk != "high" {
433            o.push("run");
434        }
435    }
436    o.extend(["reject", "stop"]);
437    o.into_iter().map(str::to_owned).collect()
438}
439
440/// A model's text, one line, at most 200 characters, control characters
441/// escaped: what it claimed in the turn that asked.
442#[must_use]
443pub fn claim(text: &str) -> Option<String> {
444    let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
445    let line = present::escape(&line);
446    if line.is_empty() {
447        return None;
448    }
449    Some(if line.chars().count() > 200 {
450        format!("{}…", line.chars().take(199).collect::<String>())
451    } else {
452        line
453    })
454}
455
456/// The risk tier of a call that needs approval, from the policy's reason.
457#[must_use]
458pub fn risk(tool: &str, reason: &str) -> &'static str {
459    let r = reason.to_lowercase();
460    if tool == "run_command" && (r.contains("arbitrary") || r.contains("network")) {
461        "high"
462    } else if tool.starts_with(crate::mcp::PREFIX)
463        || r.contains("manifest")
464        || r.contains("frozen")
465        || r.contains("repository state")
466        || r.contains("recursive")
467    {
468        "medium"
469    } else {
470        "low"
471    }
472}
473
474// ── `speccoder report --approvals` / `approve` / `reject` ────────────────────────────
475
476/// The feature, its repository root and its state directory.
477fn locate(
478    command: &str,
479    path: &Path,
480    feature: Option<&str>,
481    state: &Path,
482) -> Result<(String, std::path::PathBuf, std::path::PathBuf), Box<CommandResult>> {
483    let inspected = crate::inspect(path, feature);
484    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
485        return Err(Box::new(CommandResult::carried(command, &inspected)));
486    };
487    let Some(f) = snapshot.feature.as_ref() else {
488        return Err(Box::new(CommandResult::new(
489            command,
490            CommandStatus::SelectionNeeded,
491            inspected.message(),
492        )));
493    };
494    let root = std::path::PathBuf::from(&snapshot.root);
495    Ok((
496        f.feature.name.clone(),
497        crate::worktree::project_state_dir(state, &root),
498        root,
499    ))
500}
501
502/// Open tasks' latest attempts that stopped for approval and are undecided.
503fn pending(
504    root: &Path,
505    feature: &str,
506    open: &[String],
507    decisions: &Decisions,
508) -> Vec<(PendingCall, Grant)> {
509    let mut seen = std::collections::BTreeSet::new();
510    let mut out = Vec::new();
511    for saved in crate::attempts::list(root, feature) {
512        if !open.contains(&saved.task) || !seen.insert(saved.task.clone()) {
513            continue; // newest first: only each open task's latest attempt
514        }
515        let Some(p) = saved.pending.filter(|_| saved.outcome == "needs_approval") else {
516            continue;
517        };
518        let tool = p["tool"].as_str().unwrap_or_default().to_owned();
519        let Some(grant) = Grant::of(&tool, &p["arguments"]) else {
520            continue;
521        };
522        if decisions.decided(feature, &saved.task, &grant) {
523            continue;
524        }
525        let presentation = view(root, feature, &saved.task, &saved.reference, &tool, &p);
526        out.push((
527            PendingCall {
528                task: saved.task.clone(),
529                covers: grant.describe(),
530                tool,
531                arguments: p["arguments"].clone(),
532                reason: p["reason"].as_str().unwrap_or_default().to_owned(),
533                reference: saved.reference.clone(),
534                presentation: Some(presentation),
535                source: "attempt".to_owned(),
536                kind: "approval".to_owned(),
537                ..PendingCall::default()
538            },
539            grant,
540        ));
541    }
542    out
543}
544
545/// The saved call as the inline prompt would have shown it: the file as the
546/// saved attempt left it, the project's sandbox, the model's claim.
547fn view(
548    root: &Path,
549    feature: &str,
550    task: &str,
551    reference: &str,
552    tool: &str,
553    pending: &serde_json::Value,
554) -> speccoder_core::ApprovalView {
555    let arguments = pending["arguments"].clone();
556    let reason = pending["reason"].as_str().unwrap_or_default().to_owned();
557    let grant = Grant::of(tool, &arguments);
558    let risk = risk(tool, &reason);
559    let request = Request {
560        task: format!("{feature}/{task}"),
561        task_title: String::new(),
562        turn: pending["turn"]
563            .as_u64()
564            .and_then(|t| u32::try_from(t).ok())
565            .unwrap_or(0),
566        tool: tool.to_owned(),
567        kind: kind_of(tool, &arguments).to_owned(),
568        covers: grant.as_ref().map(|g| g.describe()).unwrap_or_default(),
569        risk: risk.to_owned(),
570        claim: pending["claim"].as_str().map(str::to_owned),
571        options: options(risk, grant.is_some()),
572        arguments,
573        reason,
574    };
575    let path = request.arguments["path"].as_str().unwrap_or_default();
576    let file_text = (!path.is_empty())
577        .then(|| {
578            crate::git::Git::at(root)
579                .run(&["show", &format!("{reference}:./{path}")])
580                .ok()
581                .map(|b| String::from_utf8_lossy(&b).into_owned())
582        })
583        .flatten();
584    presented(root, &request, task, file_text)
585}
586
587/// `request` as the inline prompt shows it, with the project's sandbox
588/// and `file_text` (the file as the call would find it) for a write.
589fn presented(
590    root: &Path,
591    request: &Request,
592    task: &str,
593    file_text: Option<String>,
594) -> speccoder_core::ApprovalView {
595    let config = crate::verify::find_config(root)
596        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
597        .unwrap_or_default();
598    let sandbox = crate::sandbox::parse(&config).ok();
599    let p = present(
600        request,
601        &PresentContext {
602            root: None,
603            sandbox: sandbox
604                .as_ref()
605                .filter(|c| c.mode != crate::sandbox::Mode::Off)
606                .map(|_| {
607                    if cfg!(target_os = "macos") {
608                        "Seatbelt".to_owned()
609                    } else {
610                        "Landlock+seccomp".to_owned()
611                    }
612                }),
613            network: match sandbox.map(|c| c.network) {
614                Some(crate::sandbox::Network::Loopback) => "loopback",
615                Some(crate::sandbox::Network::On) => "unrestricted",
616                _ => "none",
617            }
618            .to_owned(),
619            file_text,
620        },
621    );
622    speccoder_core::ApprovalView {
623        kind: request.kind.clone(),
624        title: p.title,
625        subject: p.subject,
626        body_lines: p.body,
627        sandbox: p.sandbox,
628        claim: request.claim.clone(),
629        risk: request.risk.clone(),
630        scope: format!("for the rest of {task}: {}", p.scope),
631    }
632}
633
634fn decided(feature: &str, decisions: &Decisions) -> Vec<DecidedCall> {
635    let prefix = format!("{feature}/");
636    let mut out = Vec::new();
637    for (key, d) in &decisions.tasks {
638        let Some(task) = key.strip_prefix(&prefix) else {
639            continue;
640        };
641        let last = |g: &Grant| d.decided.iter().rev().find(|x| x.grant == *g);
642        out.extend(d.approved.iter().map(|g| DecidedCall {
643            task: task.to_owned(),
644            decision: "approved".to_owned(),
645            covers: g.describe(),
646            reason: None,
647            by: last(g).map(|x| x.by.describe()),
648            scope: Some("task".to_owned()),
649            note: last(g).and_then(|x| x.note.clone()),
650            operator: last(g).map(|x| x.by.view()),
651            via: last(g).map(|x| x.by.via.clone()),
652            ..DecidedCall::default()
653        }));
654        out.extend(d.rejected.iter().map(|r| DecidedCall {
655            task: task.to_owned(),
656            decision: "rejected".to_owned(),
657            covers: r.grant.describe(),
658            reason: Some(r.reason.clone()),
659            by: last(&r.grant).map(|x| x.by.describe()),
660            scope: Some("task".to_owned()),
661            note: None,
662            operator: last(&r.grant).map(|x| x.by.view()),
663            via: last(&r.grant).map(|x| x.by.via.clone()),
664            ..DecidedCall::default()
665        }));
666        // "For this run" decisions live in the history only.
667        out.extend(
668            d.decided
669                .iter()
670                .filter(|x| x.verb == "approve" && x.scope == "run")
671                .map(|x| DecidedCall {
672                    task: task.to_owned(),
673                    decision: "approved".to_owned(),
674                    covers: x.grant.describe(),
675                    reason: None,
676                    by: Some(x.by.describe()),
677                    scope: Some("run".to_owned()),
678                    note: x.note.clone(),
679                    operator: Some(x.by.view()),
680                    via: Some(x.by.via.clone()),
681                    ..DecidedCall::default()
682                }),
683        );
684    }
685    out
686}
687
688/// Calls of `feature` waiting for a person on a human task, oldest
689/// first: the attempt is suspended and resumes once they are decided.
690fn waiting_tasks(
691    root: &Path,
692    dir: &Path,
693    feature: &str,
694) -> Vec<(PendingCall, agentplane::core::Task)> {
695    let Ok(rt) = crate::tokio_runtime() else {
696        return Vec::new();
697    };
698    let Ok(plane) = crate::substrate::Plane::open_for_decisions(dir) else {
699        return Vec::new();
700    };
701    // A call past its deadline is denied as expired before it is listed.
702    let _ = rt.block_on(plane.expire_due(agentplane::core::Timestamp::now_utc()));
703    let open = rt.block_on(plane.open_tasks()).unwrap_or_default();
704    let prefix = format!("{feature}/");
705    open.into_iter()
706        .filter(|t| t.kind == tasks::APPROVAL)
707        .filter_map(|t| {
708            let request = tasks::request_of(&t.justification.proposed_action)?;
709            let task = request.task.strip_prefix(&prefix)?.to_owned();
710            let view = presented(root, &request, &task, None);
711            Some((
712                PendingCall {
713                    task,
714                    covers: request.covers.clone(),
715                    tool: request.tool.clone(),
716                    arguments: request.arguments.clone(),
717                    reason: request.reason.clone(),
718                    reference: String::new(),
719                    presentation: Some(view),
720                    call: t.id.to_hex(),
721                    source: "task".to_owned(),
722                    kind: tasks::APPROVAL.to_owned(),
723                    question: None,
724                    waiting_since: Some(t.created_at.unix_timestamp().max(0).unsigned_abs()),
725                    deadline: t.due_at.map(|d| d.unix_timestamp().max(0).unsigned_abs()),
726                },
727                t,
728            ))
729        })
730        .collect()
731}
732
733/// Denies, as expired, every call in the journal at `dir` whose deadline
734/// passed at `now` (nobody's decision); their attempts read the refusal
735/// when they next resume.
736pub fn expire_due(dir: &Path, now: agentplane::core::Timestamp) -> Result<(), String> {
737    let rt = crate::tokio_runtime()?;
738    let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
739    rt.block_on(plane.expire_due(now)).map_err(|e| e.0)
740}
741
742/// Another task of `feature` whose attempt waits for a person, with what
743/// it waits on: its attempt holds the feature's worktree until it is
744/// decided and resumed.
745#[must_use]
746pub fn waiting_elsewhere(
747    root: &Path,
748    dir: &Path,
749    feature: &str,
750    task: &str,
751) -> Option<(String, String)> {
752    waiting_tasks(root, dir, feature)
753        .into_iter()
754        .map(|(c, _)| c)
755        .find(|c| c.task != task && c.source == "task")
756        .map(|c| (c.task, c.covers))
757}
758
759/// Withdraws every call of `feature` waiting on a person: their attempts
760/// were set aside (`--return`, changed intent), so nothing resumes them.
761pub fn withdraw_waiting(
762    root: &Path,
763    dir: &Path,
764    feature: &str,
765    reason: &str,
766) -> Result<usize, String> {
767    let rows = waiting_tasks(root, dir, feature);
768    if rows.is_empty() {
769        return Ok(0);
770    }
771    let rt = crate::tokio_runtime()?;
772    let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
773    let by = Who::local_in(root, "cli")
774        .operator()
775        .ok_or_else(|| "no name to record the withdrawal under".to_owned())?;
776    for (_, task) in &rows {
777        rt.block_on(plane.withdraw(task, &by, reason))
778            .map_err(|e| e.0)?;
779    }
780    Ok(rows.len())
781}
782
783/// Records `answer` on the waiting call `task` through a plane without the
784/// attempt's skill: the run continues when it next resumes.
785fn decide_waiting(
786    dir: &Path,
787    task: &agentplane::core::Task,
788    answer: &Answer,
789    by: &Who,
790) -> Result<(), String> {
791    let rt = crate::tokio_runtime()?;
792    let plane = crate::substrate::Plane::open_for_decisions(dir).map_err(|e| e.0)?;
793    let decision = tasks::decision_for(answer, by, None)
794        .ok_or_else(|| format!("`{}` is not a name a decision can carry", by.name))?;
795    rt.block_on(plane.decide(task.id, &decision))
796        .map(|_| ())
797        .map_err(|e| e.0)
798}
799
800/// What to do with a pending call.
801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum Verb<'a> {
803    /// Only list.
804    List,
805    /// Approve the task's pending call.
806    Approve(&'a str),
807    /// Approve it with a note the next attempt is told.
808    ApproveNoted(&'a str, &'a str),
809    /// Reject the task's pending call, with a reason.
810    Reject(&'a str, &'a str),
811    /// Decide waiting calls as `speccoder task approve|reject` options say.
812    Decide(Choice<'a>),
813}
814
815/// Which waiting calls to decide, and how (`approve`/`reject` options).
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817pub struct Choice<'a> {
818    /// The task's calls (`--task`); all of the feature's with `--all` alone.
819    pub task: Option<&'a str>,
820    /// One call by its id or a prefix of it (`--call`).
821    pub call: Option<&'a str>,
822    /// Every matching call (`--all`), each decided and recorded on its own.
823    pub all: bool,
824    /// Approve, or reject.
825    pub approve: bool,
826    /// How far an approval reaches (`--scope`, default `task`).
827    pub scope: Scope,
828    /// An approval's note to the model.
829    pub note: Option<&'a str>,
830    /// A rejection's reason, which the model is told.
831    pub reason: Option<&'a str>,
832}
833
834impl<'a> Choice<'a> {
835    /// Approve the task's waiting call (for the task), with an optional note.
836    #[must_use]
837    pub fn approve(task: &'a str, note: Option<&'a str>) -> Self {
838        Self {
839            task: Some(task),
840            call: None,
841            all: false,
842            approve: true,
843            scope: Scope::Task,
844            note,
845            reason: None,
846        }
847    }
848
849    /// Reject the task's waiting call; the model is told `reason`.
850    #[must_use]
851    pub fn reject(task: &'a str, reason: &'a str) -> Self {
852        Self {
853            task: Some(task),
854            call: None,
855            all: false,
856            approve: false,
857            scope: Scope::Task,
858            note: None,
859            reason: Some(reason),
860        }
861    }
862}
863
864/// `approvals`, `approve --task` and `reject --task --reason` against `state`.
865pub fn decide_with(
866    path: &Path,
867    feature: Option<&str>,
868    verb: Verb<'_>,
869    state: &Path,
870) -> CommandResult {
871    let command = match verb {
872        Verb::List => "approvals",
873        Verb::Approve(_) | Verb::ApproveNoted(..) => "approve",
874        Verb::Reject(..) => "reject",
875        Verb::Decide(c) if c.approve => "approve",
876        Verb::Decide(_) => "reject",
877    };
878    let (feature, dir, root) = match locate(command, path, feature, state) {
879        Ok(x) => x,
880        Err(r) => return *r,
881    };
882    let mut decisions = match Decisions::load(&dir) {
883        Ok(d) => d,
884        Err(e) => return CommandResult::new(command, CommandStatus::ConfigError, &e),
885    };
886    // Open tasks, from the speccoder branch when there is one.
887    let status = crate::status::report(&root, Some(&feature));
888    let open: Vec<String> = match status.data() {
889        Some(CommandData::Report(r)) => r
890            .tasks
891            .iter()
892            .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
893            .map(|t| t.id.clone())
894            .collect(),
895        _ => Vec::new(),
896    };
897    let tasks_waiting = waiting_tasks(&root, &dir, &feature);
898    let waiting = pending(&root, &feature, &open, &decisions);
899
900    // A call waiting on a task is decided there: who, how known, bound to
901    // what was shown. Its attempt continues on the next run.
902    let choice = match verb {
903        Verb::List => None,
904        Verb::Approve(t) => Some(Choice::approve(t, None)),
905        Verb::ApproveNoted(t, n) => Some(Choice::approve(t, Some(n))),
906        Verb::Reject(t, r) => Some(Choice::reject(t, r)),
907        Verb::Decide(c) => Some(c),
908    };
909    if let Some(c) = choice {
910        let matching: Vec<&(PendingCall, agentplane::core::Task)> = tasks_waiting
911            .iter()
912            .filter(|(p, _)| c.task.is_none_or(|t| p.task == t))
913            .filter(|(p, _)| {
914                c.call
915                    .is_none_or(|id| !id.is_empty() && p.call.starts_with(id))
916            })
917            .collect();
918        if matching.len() > 1 && !c.all {
919            let list: Vec<String> = matching
920                .iter()
921                .map(|(p, _)| {
922                    format!(
923                        "{} ({}, {})",
924                        &p.call[..12.min(p.call.len())],
925                        p.task,
926                        p.covers
927                    )
928                })
929                .collect();
930            return CommandResult::new(
931                command,
932                CommandStatus::UsageError,
933                &format!(
934                    "{} calls are waiting; name one with --call, or decide them all with --all: {}",
935                    matching.len(),
936                    list.join(", ")
937                ),
938            );
939        }
940        if !matching.is_empty() {
941            let by = Who::local_in(&root, "cli");
942            let mut lines = Vec::new();
943            for (call, row) in &matching {
944                let answer = if c.approve {
945                    Answer::approve(c.scope, c.note.map(str::to_owned), Some(by.clone()))
946                } else {
947                    Answer::reject(c.reason.unwrap_or("no reason given"), Some(by.clone()))
948                };
949                if let Err(e) = decide_waiting(&dir, row, &answer, &by) {
950                    return CommandResult::new(command, CommandStatus::Error, &e);
951                }
952                lines.push(if c.approve {
953                    format!(
954                        "approved: {} for {} ({}) — by {} ({})",
955                        call.covers,
956                        call.task,
957                        c.scope.name(),
958                        by.name,
959                        by.basis
960                    )
961                } else {
962                    format!(
963                        "rejected: {} for {} — by {} ({}); the model will be told: {}",
964                        call.covers,
965                        call.task,
966                        by.name,
967                        by.basis,
968                        c.reason.unwrap_or("no reason given")
969                    )
970                });
971            }
972            let report = ApprovalsReport {
973                decided: decided(&feature, &decisions),
974                pending: waiting_tasks(&root, &dir, &feature)
975                    .into_iter()
976                    .map(|(c, _)| c)
977                    .chain(
978                        pending(&root, &feature, &open, &decisions)
979                            .into_iter()
980                            .map(|(c, _)| c),
981                    )
982                    .collect(),
983                feature,
984            };
985            return CommandResult::new(
986                command,
987                CommandStatus::Ok,
988                &format!(
989                    "{}; `speccoder run` continues each attempt where it stopped",
990                    lines.join("\n")
991                ),
992            )
993            .with_data(CommandData::Approvals(Box::new(report)));
994        }
995        if c.all || c.call.is_some() || c.task.is_none() {
996            return CommandResult::new(
997                command,
998                CommandStatus::NotFound,
999                "no call matching that awaits a decision (see `speccoder report --approvals`)",
1000            );
1001        }
1002    }
1003    // In a parallel lane, a stopped attempt is saved with its call:
1004    // deciding it records a grant the task's next attempt starts with.
1005    let verb = match verb {
1006        Verb::Decide(c) => match (c.task, c.approve, c.note) {
1007            (Some(t), true, Some(n)) => Verb::ApproveNoted(t, n),
1008            (Some(t), true, None) => Verb::Approve(t),
1009            (Some(t), false, _) => Verb::Reject(t, c.reason.unwrap_or("no reason given")),
1010            (None, ..) => Verb::List,
1011        },
1012        v => v,
1013    };
1014
1015    let message = match verb {
1016        // Mapped onto the verbs above before this point.
1017        Verb::List | Verb::Decide(_) => {
1018            let n = waiting.len() + tasks_waiting.len();
1019            if n == 0 {
1020                "nothing awaits a decision".to_owned()
1021            } else {
1022                format!("{n} call(s) await a decision")
1023            }
1024        }
1025        Verb::Approve(task) | Verb::ApproveNoted(task, _) | Verb::Reject(task, _) => {
1026            let Some((call, grant)) = waiting.iter().find(|(c, _)| c.task == task) else {
1027                return CommandResult::new(
1028                    command,
1029                    CommandStatus::NotFound,
1030                    &format!(
1031                        "{task} has no call awaiting a decision (see `speccoder report --approvals`)"
1032                    ),
1033                );
1034            };
1035            let what = match verb {
1036                Verb::Reject(_, reason) => {
1037                    decisions.reject(
1038                        &feature,
1039                        task,
1040                        grant.clone(),
1041                        reason,
1042                        &Who::local_in(&root, "cli"),
1043                    );
1044                    format!(
1045                        "rejected: {} for {task} — the model will be told: {reason}",
1046                        call.covers
1047                    )
1048                }
1049                _ => {
1050                    decisions.approve(&feature, task, grant.clone(), &Who::local_in(&root, "cli"));
1051                    if let Verb::ApproveNoted(_, note) = verb
1052                        && !note.trim().is_empty()
1053                    {
1054                        let t = decisions
1055                            .tasks
1056                            .entry(format!("{feature}/{task}"))
1057                            .or_default();
1058                        if let Some(last) = t.decided.last_mut() {
1059                            last.note = Some(note.trim().to_owned());
1060                        }
1061                        // The next attempt is told, beside the approval.
1062                        t.nudges.push(format!(
1063                            "A person approved {}: {}",
1064                            call.covers,
1065                            note.trim()
1066                        ));
1067                    }
1068                    format!("approved: {} for {task}", call.covers)
1069                }
1070            };
1071            if let Err(e) = decisions.save(&dir) {
1072                return CommandResult::new(command, CommandStatus::Error, &e);
1073            }
1074            format!("{what}; run the task again to continue (speccoder run --task {task})")
1075        }
1076    };
1077    let report = ApprovalsReport {
1078        decided: decided(&feature, &decisions),
1079        pending: tasks_waiting
1080            .into_iter()
1081            .map(|(c, _)| c)
1082            .chain(
1083                pending(&root, &feature, &open, &decisions)
1084                    .into_iter()
1085                    .map(|(c, _)| c),
1086            )
1087            .collect(),
1088        feature,
1089    };
1090    CommandResult::new(command, CommandStatus::Ok, &message)
1091        .with_data(CommandData::Approvals(Box::new(report)))
1092}
1093
1094/// [`decide_with`] in the default state directory.
1095#[must_use]
1096pub fn decide(path: &Path, feature: Option<&str>, verb: Verb<'_>) -> CommandResult {
1097    match crate::worktree::state_dir() {
1098        Some(state) => decide_with(path, feature, verb, &state),
1099        None => CommandResult::new(
1100            "approvals",
1101            CommandStatus::ConfigError,
1102            "no state directory (set SPECCODER_STATE_DIR or HOME)",
1103        )
1104        .with_code("NO-STATE-DIR"),
1105    }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111
1112    #[test]
1113    fn decisions_round_trip_and_replace_each_other() {
1114        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1115        let g = Grant::Write {
1116            path: "Cargo.toml".to_owned(),
1117        };
1118        let mut d = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
1119        let by = Who::local("cli");
1120        d.reject("f", "T1", g.clone(), "no new dependencies", &by);
1121        d.approve("f", "T1", g.clone(), &by);
1122        assert_eq!(d.tasks["f/T1"].decided.len(), 2);
1123        assert_eq!(d.tasks["f/T1"].decided[1].by, by);
1124        d.save(dir.path()).unwrap_or_else(|e| panic!("{e}"));
1125        let back = Decisions::load(dir.path()).unwrap_or_else(|e| panic!("{e}"));
1126        assert_eq!(back.for_task("f", "T1").approved, std::slice::from_ref(&g));
1127        assert!(back.for_task("f", "T1").rejected.is_empty());
1128        assert!(back.decided("f", "T1", &g) && !back.decided("f", "T2", &g));
1129    }
1130}