Skip to main content

speccoder_runtime/format/openspec/
archive.rs

1//! `speccoder spec archive`: an OpenSpec change merged into the main specs,
2//! by the person, diff first. Never called by the
3//! engine, the model or a hosted skill.
4//!
5//! OpenSpec's `archive --yes` also skips its incomplete-task check, so the
6//! first call is always without `--yes`: OpenSpec answers whether tasks are
7//! open or a confirmation is needed, and only then does a person's yes (or
8//! `--yes` given to speccoder) archive.
9
10use std::collections::BTreeSet;
11use std::path::{Path, PathBuf};
12
13use serde_json::Value;
14use speccoder_core::{ArchiveReport, CommandData, CommandResult, CommandStatus};
15
16use crate::git::Git;
17use crate::verify::process::LocalRunner;
18
19/// How the archive is confirmed.
20pub struct Options<'a> {
21    /// The change (else the only active one).
22    pub change: Option<&'a str>,
23    /// Confirmed in advance (`--yes`).
24    pub yes: bool,
25    /// Tasks ticked without a verdict that the person accepts by name.
26    pub allow_unverified: &'a [String],
27    /// Asks the person, after the diff is shown; `None`: nobody to ask.
28    pub confirm: Option<&'a dyn Fn(&ArchiveReport) -> bool>,
29    /// The `openspec` program (default: from PATH).
30    pub program: Option<PathBuf>,
31}
32
33const COMMAND: &str = "spec";
34
35fn result(status: CommandStatus, message: &str, report: ArchiveReport) -> CommandResult {
36    CommandResult::new(COMMAND, status, message).with_data(CommandData::Archive(Box::new(report)))
37}
38
39/// The last JSON object OpenSpec printed.
40fn json(text: &str) -> Option<Value> {
41    let start = text.find('{')?;
42    serde_json::from_str(&text[start..]).ok()
43}
44
45/// OpenSpec's first error status, if any.
46fn error(answer: &Value) -> Option<&Value> {
47    answer["status"]
48        .as_array()
49        .and_then(|s| s.iter().find(|e| e["severity"] == "error"))
50}
51
52/// Tasks of the change ticked in `root`'s checkout without a speccoder
53/// verdict commit (`Spec-Task: <change>/<id>` with a `Verdict:`) on HEAD.
54fn unverified(root: &Path, change: &str, done: &[String]) -> Vec<String> {
55    let log = Git::at(root)
56        .run(&[
57            "log",
58            "--format=%(trailers:key=Spec-Task,valueonly)%x1f%(trailers:key=Verdict,valueonly)%x1e",
59            "HEAD",
60        ])
61        .map(|b| String::from_utf8_lossy(&b).into_owned())
62        .unwrap_or_default();
63    let verdicts: BTreeSet<String> = log
64        .split('\u{1e}')
65        .filter_map(|rec| {
66            let (task, verdict) = rec.split_once('\u{1f}')?;
67            (!verdict.trim().is_empty()).then(|| task.trim().to_owned())
68        })
69        .collect();
70    done.iter()
71        .filter(|id| !verdicts.contains(&format!("{change}/{id}")))
72        .cloned()
73        .collect()
74}
75
76/// Archives the selected change of the OpenSpec project at `path`.
77#[must_use]
78pub fn archive(path: &Path, opts: &Options<'_>) -> CommandResult {
79    let refuse = |status, message: &str| CommandResult::new(COMMAND, status, message);
80    let opened = match crate::format::open_with(
81        path,
82        opts.change,
83        Some(speccoder_core::spec::FormatKind::Openspec),
84    ) {
85        Ok(o) => o,
86        Err(r) => return refuse(r.status, &r.message),
87    };
88    let snapshot = opened.snapshot;
89    let Some(f) = snapshot.feature.as_ref() else {
90        let why = snapshot
91            .diagnostics
92            .iter()
93            .find(|d| d.code.starts_with("select."))
94            .map_or("no change selected", |d| d.message.as_str());
95        return refuse(
96            CommandStatus::SelectionNeeded,
97            &format!("{why}; name the change"),
98        );
99    };
100    let id = f.feature.name.clone();
101    let root = PathBuf::from(&snapshot.root);
102    let mut report = ArchiveReport {
103        change: id.clone(),
104        stage: "probe".to_owned(),
105        openspec_status: None,
106        unverified_tasks: Vec::new(),
107        diff: None,
108        archived_as: None,
109        totals: None,
110        commit: None,
111        confirmed_by: None,
112    };
113    let Some(program) = opts.program.clone().or_else(super::cli::find) else {
114        return refuse(
115            CommandStatus::NotFound,
116            "archiving needs OpenSpec's CLI: install it with `npm i -g @fission-ai/openspec` (Node 20.19 or newer)",
117        );
118    };
119    let git = Git::at(&root);
120    let dirty = git
121        .run(&["status", "--porcelain", "--", "openspec"])
122        .map(|b| !b.is_empty())
123        .unwrap_or(true);
124    if dirty {
125        return refuse(
126            CommandStatus::GitPrecondition,
127            "openspec/ has uncommitted changes; commit them first, so the archive commit holds only the archive",
128        )
129            .with_code("UNCOMMITTED-INTENT");
130    }
131
132    // Ticks without evidence are the person's to own, task by task.
133    let done: Vec<String> = f
134        .tasks
135        .iter()
136        .flat_map(|t| &t.tasks)
137        .filter(|t| t.done)
138        .map(|t| t.id.clone())
139        .collect();
140    let unproven = unverified(&root, &id, &done);
141    let unaccepted: Vec<String> = unproven
142        .iter()
143        .filter(|t| !opts.allow_unverified.contains(t))
144        .cloned()
145        .collect();
146    report.unverified_tasks = unproven.clone();
147    if !unaccepted.is_empty() {
148        report.stage = "unverified".to_owned();
149        return result(
150            CommandStatus::NeedsHuman,
151            &format!(
152                "{} ticked without a speccoder verdict: {}; run them, accept them (`speccoder task accept`), or name each with --allow-unverified",
153                id,
154                unaccepted.join(", ")
155            ),
156            report,
157        );
158    }
159
160    // The probe: never `--yes`, so OpenSpec's own checks run.
161    let runner = LocalRunner;
162    let probe = match super::cli::run(&runner, &program, &root, &["archive", &id, "--json"]) {
163        Ok(out) => json(&out),
164        Err(e) => {
165            report.stage = "failed".to_owned();
166            return result(
167                CommandStatus::RunFailed,
168                &format!("openspec archive: {e}"),
169                report,
170            );
171        }
172    };
173    let Some(probe) = probe else {
174        report.stage = "failed".to_owned();
175        return result(
176            CommandStatus::RunFailed,
177            "openspec archive printed no JSON",
178            report,
179        );
180    };
181    let finish = |mut report: ArchiveReport, answer: &Value| -> CommandResult {
182        report.archived_as = answer["archive"]["archivedAs"].as_str().map(str::to_owned);
183        report.totals = Some(answer["archive"]["totals"].clone()).filter(|t| !t.is_null());
184        let who = crate::approvals::who();
185        let mut message = format!(
186            "archive {id}\n\nArchived-By: {who}\nSpeccoder-Archive: {}\n",
187            report.archived_as.as_deref().unwrap_or(&id)
188        );
189        if !unproven.is_empty() {
190            message.push_str(&format!("Archived-Unverified: {}\n", unproven.join(", ")));
191        }
192        let committed = git
193            .run(&["add", "-A", "--", "openspec"])
194            .and_then(|_| {
195                git.run(&[
196                    "-c",
197                    "commit.gpgsign=false",
198                    "commit",
199                    "-q",
200                    "--no-verify",
201                    "-m",
202                    &message,
203                ])
204            })
205            .and_then(|_| git.line(&["rev-parse", "HEAD"]));
206        match committed {
207            Ok(sha) => {
208                report.commit = Some(sha);
209                report.confirmed_by = Some(who);
210                report.stage = "archived".to_owned();
211                let m = format!(
212                    "{id} archived as {} and committed",
213                    report.archived_as.as_deref().unwrap_or(&id)
214                );
215                result(CommandStatus::Ok, &m, report)
216            }
217            Err(e) => {
218                report.stage = "failed".to_owned();
219                result(
220                    CommandStatus::GitPrecondition,
221                    &format!("OpenSpec archived {id}, but committing failed: {}", e.0),
222                    report,
223                )
224            }
225        }
226    };
227    if !probe["archive"].is_null() {
228        // Nothing to confirm: OpenSpec archived on the probe.
229        return finish(report, &probe);
230    }
231    let Some(status) = error(&probe) else {
232        report.stage = "failed".to_owned();
233        return result(
234            CommandStatus::RunFailed,
235            "openspec archive answered neither success nor an error",
236            report,
237        );
238    };
239    report.openspec_status = Some(status.clone());
240    let code = status["code"].as_str().unwrap_or_default();
241    let said = |s: &Value| {
242        let fix = s["fix"]
243            .as_str()
244            .map(|f| format!(" — {f}"))
245            .unwrap_or_default();
246        format!("{}{fix}", s["message"].as_str().unwrap_or_default())
247    };
248    match code {
249        "archive_tasks_incomplete" => {
250            report.stage = "incomplete".to_owned();
251            return result(
252                CommandStatus::NeedsHuman,
253                &format!("OpenSpec: {}", said(status)),
254                report,
255            );
256        }
257        "archive_confirmation_required" => {}
258        _ => {
259            report.stage = "failed".to_owned();
260            return result(
261                CommandStatus::RunFailed,
262                &format!("OpenSpec refused ({code}): {}", said(status)),
263                report,
264            );
265        }
266    }
267
268    // What archiving changes, then the person's yes.
269    report.diff = super::cli::run(&runner, &program, &root, &["show", &id, "--diff"]).ok();
270    report.stage = "awaiting_confirmation".to_owned();
271    let yes = opts.yes || opts.confirm.is_some_and(|ask| ask(&report));
272    if !yes {
273        if opts.confirm.is_some() {
274            report.stage = "declined".to_owned();
275            return result(
276                CommandStatus::Ok,
277                &format!("{id} not archived; nothing changed"),
278                report,
279            );
280        }
281        return result(
282            CommandStatus::NeedsHuman,
283            &format!("archiving {id} rewrites the main specs and needs your yes: rerun with --yes"),
284            report,
285        );
286    }
287    let answer = match super::cli::run(
288        &runner,
289        &program,
290        &root,
291        &["archive", &id, "--yes", "--json"],
292    )
293    .ok()
294    .and_then(|o| json(&o))
295    {
296        Some(a) => a,
297        None => {
298            report.stage = "failed".to_owned();
299            return result(
300                CommandStatus::RunFailed,
301                "openspec archive --yes printed no JSON",
302                report,
303            );
304        }
305    };
306    if answer["archive"].is_null() {
307        report.openspec_status = error(&answer).cloned();
308        report.stage = "failed".to_owned();
309        let why = report
310            .openspec_status
311            .as_ref()
312            .map(said)
313            .unwrap_or_default();
314        return result(
315            CommandStatus::RunFailed,
316            &format!("OpenSpec did not archive {id}: {why}"),
317            report,
318        );
319    }
320    finish(report, &answer)
321}