1use 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
21pub const FILE: &str = "approvals.json";
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct TaskDecisions {
27 #[serde(default)]
29 pub approved: Vec<Grant>,
30 #[serde(default)]
32 pub rejected: Vec<Rejection>,
33 #[serde(default)]
35 pub nudges: Vec<String>,
36 #[serde(default)]
38 pub decided: Vec<Decided>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Who {
44 pub name: String,
46 pub via: String,
48 #[serde(default = "asserted")]
52 pub basis: String,
53}
54
55fn asserted() -> String {
56 "asserted".to_owned()
57}
58
59impl Who {
60 #[must_use]
64 pub fn local(via: &str) -> Self {
65 Self::local_in(Path::new("."), via)
66 }
67
68 #[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 #[must_use]
85 pub fn describe(&self) -> String {
86 format!("{} ({})", self.name, self.via)
87 }
88
89 #[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 #[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#[must_use]
109pub fn who() -> String {
110 who_in(Path::new("."))
111}
112
113#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct Decided {
131 pub verb: String,
133 pub grant: Grant,
135 #[serde(default)]
137 pub reason: Option<String>,
138 #[serde(default = "task_scope")]
141 pub scope: String,
142 #[serde(default)]
144 pub note: Option<String>,
145 pub by: Who,
147 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
163pub struct Decisions {
164 #[serde(default)]
166 pub tasks: BTreeMap<String, TaskDecisions>,
167}
168
169impl Decisions {
170 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 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 #[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 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 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 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 #[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
254pub mod present;
257pub mod tasks;
258
259pub use present::{PresentContext, Presentation, present};
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct Request {
266 pub task: String,
268 #[serde(default)]
270 pub task_title: String,
271 #[serde(default)]
273 pub turn: u32,
274 pub tool: String,
276 pub arguments: serde_json::Value,
278 #[serde(default)]
280 pub kind: String,
281 pub reason: String,
283 pub covers: String,
285 pub risk: String,
288 #[serde(default)]
291 pub claim: Option<String>,
292 #[serde(default)]
294 pub options: Vec<String>,
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case")]
300pub enum Scope {
301 Once,
303 Task,
305 Run,
307}
308
309impl Scope {
310 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum Decision {
325 Approve,
327 Reject,
329 Stop,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct Answer {
336 pub decision: Decision,
338 pub scope: Scope,
340 #[serde(default)]
342 pub note: Option<String>,
343 #[serde(default)]
345 pub reason: Option<String>,
346 #[serde(default)]
348 pub by: Option<Who>,
349}
350
351impl Answer {
352 #[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 #[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 #[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
389pub trait Approver: Send + Sync + std::fmt::Debug {
393 fn ask(&self, p: &Presentation) -> Answer;
395
396 fn answer(&self, question: &str) -> Option<String> {
399 let _ = question;
400 None
401 }
402
403 fn via(&self) -> String {
405 "terminal".to_owned()
406 }
407}
408
409#[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#[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#[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#[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
474fn 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
502fn 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; }
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
545fn 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
587fn 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 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
688fn 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 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
733pub 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#[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
759pub 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
783fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum Verb<'a> {
803 List,
805 Approve(&'a str),
807 ApproveNoted(&'a str, &'a str),
809 Reject(&'a str, &'a str),
811 Decide(Choice<'a>),
813}
814
815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817pub struct Choice<'a> {
818 pub task: Option<&'a str>,
820 pub call: Option<&'a str>,
822 pub all: bool,
824 pub approve: bool,
826 pub scope: Scope,
828 pub note: Option<&'a str>,
830 pub reason: Option<&'a str>,
832}
833
834impl<'a> Choice<'a> {
835 #[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 #[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
864pub 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 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 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 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 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 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#[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}