Skip to main content

speccoder_runtime/
explore.rs

1//! `spec explore`: an idea explored read-only by the format's own skill
2//! (or speccoder's fallback), ending in approaches the person chooses
3//! from; the choice composes the brief that starts the spec. Sessions are
4//! files in the project's state, never in the checkout.
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use agentplane::model::ModelProvider;
10use serde_json::Value;
11use speccoder_core::actions::Via;
12use speccoder_core::explore::{
13    self as core, Choice, ExplorePayload, ExploreReport, ExploreSession, FALLBACK, Item, QaItem,
14    RunRef,
15};
16use speccoder_core::{CommandData, CommandResult, CommandStatus};
17
18use crate::RunOptions;
19
20fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
21    CommandResult::new("explore", status, message).with_code(code)
22}
23
24fn now() -> u64 {
25    crate::bug::confirm::now()
26}
27
28/// Whether `id` has the shape of a session id (`ex-` and hex).
29#[must_use]
30pub fn valid_id(id: &str) -> bool {
31    id.strip_prefix("ex-")
32        .is_some_and(|h| !h.is_empty() && h.len() <= 40 && h.bytes().all(|b| b.is_ascii_hexdigit()))
33}
34
35fn new_id() -> String {
36    let nanos = std::time::SystemTime::now()
37        .duration_since(std::time::UNIX_EPOCH)
38        .map_or(0, |d| d.as_nanos());
39    format!("ex-{:012x}", (nanos / 1000) & 0xffff_ffff_ffff)
40}
41
42/// The project's root and its Explore folder.
43fn dirs(path: &Path, state: &Path) -> Result<(PathBuf, PathBuf), Box<CommandResult>> {
44    let Some(root) = crate::format::at(path).detect(path) else {
45        return Err(Box::new(refuse(
46            CommandStatus::NotAProject,
47            "NO-PROJECT",
48            "not a spec project here",
49        )));
50    };
51    let dir = crate::worktree::project_state_dir(state, &root).join("explore");
52    Ok((root, dir))
53}
54
55fn session_path(dir: &Path, id: &str) -> PathBuf {
56    dir.join(format!("{id}.json"))
57}
58
59fn brief_path(dir: &Path, id: &str) -> PathBuf {
60    dir.join(format!("{id}.brief.md"))
61}
62
63fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
64    if let Some(parent) = path.parent() {
65        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
66    }
67    let tmp = path.with_extension("tmp");
68    std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
69    std::fs::rename(&tmp, path).map_err(|e| e.to_string())
70}
71
72fn load(dir: &Path, id: &str) -> Option<ExploreSession> {
73    if !valid_id(id) {
74        return None;
75    }
76    serde_json::from_str(&std::fs::read_to_string(session_path(dir, id)).ok()?).ok()
77}
78
79fn save(dir: &Path, s: &ExploreSession) -> Result<(), String> {
80    let text = serde_json::to_vec_pretty(s).map_err(|e| e.to_string())?;
81    write_atomic(&session_path(dir, &s.id), &text)
82}
83
84fn sessions(dir: &Path) -> Vec<ExploreSession> {
85    let mut all: Vec<ExploreSession> = std::fs::read_dir(dir)
86        .into_iter()
87        .flatten()
88        .flatten()
89        .filter_map(|e| {
90            let name = e.file_name().to_string_lossy().into_owned();
91            let id = name.strip_suffix(".json")?;
92            load(dir, id)
93        })
94        .collect();
95    all.sort_by(|a, b| b.updated_at.cmp(&a.updated_at).then(b.id.cmp(&a.id)));
96    all
97}
98
99fn payload(dir: &Path, s: ExploreSession) -> ExplorePayload {
100    let (approaches, constraints, open_questions) = s.on_the_table();
101    // A choice and its brief stand until a resume puts new approaches on
102    // the table: then the person chooses again.
103    let choice = (s.state != "open")
104        .then(|| s.choices.last().cloned())
105        .flatten();
106    let brief = choice
107        .as_ref()
108        .and_then(|_| std::fs::read_to_string(brief_path(dir, &s.id)).ok());
109    ExplorePayload {
110        choice,
111        session: Some(s),
112        approaches,
113        constraints,
114        open_questions,
115        brief,
116        sessions: None,
117    }
118}
119
120fn result(status: CommandStatus, message: &str, p: ExplorePayload) -> CommandResult {
121    CommandResult::new("explore", status, message).with_data(CommandData::Explore(Box::new(p)))
122}
123
124/// The project's sessions, newest first.
125#[must_use]
126pub fn list(path: &Path, state: Option<&Path>) -> CommandResult {
127    let Some(state) = state
128        .map(Path::to_path_buf)
129        .or_else(crate::worktree::state_dir)
130    else {
131        return refuse(CommandStatus::Error, "ERROR", "no state directory");
132    };
133    let (_, dir) = match dirs(path, &state) {
134        Ok(d) => d,
135        Err(e) => return *e,
136    };
137    let entries: Vec<_> = sessions(&dir).iter().map(ExploreSession::entry).collect();
138    let message = match entries.len() {
139        0 => "no explorations yet: `speccoder spec explore \"<idea>\"`".to_owned(),
140        n => format!("{n} exploration(s)"),
141    };
142    result(
143        CommandStatus::Ok,
144        &message,
145        ExplorePayload {
146            session: None,
147            approaches: Vec::new(),
148            constraints: Vec::new(),
149            open_questions: Vec::new(),
150            choice: None,
151            brief: None,
152            sessions: Some(entries),
153        },
154    )
155}
156
157/// One session as it stands.
158#[must_use]
159pub fn session(path: &Path, state: Option<&Path>, id: &str) -> CommandResult {
160    let Some(state) = state
161        .map(Path::to_path_buf)
162        .or_else(crate::worktree::state_dir)
163    else {
164        return refuse(CommandStatus::Error, "ERROR", "no state directory");
165    };
166    let (_, dir) = match dirs(path, &state) {
167        Ok(d) => d,
168        Err(e) => return *e,
169    };
170    match load(&dir, id) {
171        Some(s) => {
172            let title = s.idea.clone();
173            result(CommandStatus::Ok, &title, payload(&dir, s))
174        }
175        None => refuse(
176            CommandStatus::NotFound,
177            "NOT-FOUND",
178            &format!("no exploration {id} — see `speccoder spec explore list`"),
179        ),
180    }
181}
182
183/// The skill that explores here, and why: `--skill`, else the format's
184/// own if installed and hosted, else the fallback.
185fn select(root: &Path, flag: Option<&str>) -> Result<(String, &'static str), String> {
186    if let Some(name) = flag {
187        return Ok((name.to_owned(), "flag"));
188    }
189    let planning = crate::format::at(root).planning();
190    let installed = crate::skills::discover_with(root, planning);
191    let found = planning.explores.and_then(|short| {
192        let name = format!("{}{short}", planning.prefix);
193        installed
194            .into_iter()
195            .find(|k| k.name == name && k.invocable && k.refused.is_none())
196    });
197    Ok(found.map_or_else(|| (FALLBACK.to_owned(), "fallback"), |k| (k.name, "format")))
198}
199
200/// What a run asked the person and what they answered, from its calls.
201fn answers(calls: &[Value]) -> Vec<QaItem> {
202    calls
203        .iter()
204        .filter(|c| c["tool"] == crate::substrate::attempt::ASK_PERSON)
205        .map(|c| QaItem {
206            question: c["arguments"]["question"]
207                .as_str()
208                .unwrap_or_default()
209                .to_owned(),
210            answer: c["answer"].as_str().map(str::to_owned),
211            by: c["operator"]["actor"].as_str().map(str::to_owned),
212        })
213        .collect()
214}
215
216/// `spec explore "<idea>"` (or `--resume ID ["words"]`) with the model
217/// from `speccoder.toml`.
218#[must_use]
219pub fn explore(
220    path: &Path,
221    idea: &str,
222    skill: Option<&str>,
223    resume: Option<&str>,
224    options: &RunOptions,
225) -> CommandResult {
226    crate::with_driver("explore", path, None, |state, provider, key| {
227        explore_in(
228            path,
229            idea,
230            skill,
231            resume,
232            state,
233            Arc::clone(provider),
234            Some(key),
235            options,
236        )
237    })
238}
239
240/// [`explore`] with `provider` answering.
241#[allow(clippy::too_many_arguments)]
242#[must_use]
243pub fn explore_in(
244    path: &Path,
245    idea: &str,
246    skill: Option<&str>,
247    resume: Option<&str>,
248    state: &Path,
249    provider: Arc<dyn ModelProvider>,
250    secret: Option<&str>,
251    options: &RunOptions,
252) -> CommandResult {
253    let (root, dir) = match dirs(path, state) {
254        Ok(d) => d,
255        Err(e) => return *e,
256    };
257    let words = idea.trim();
258    let (mut session, input, kind) = match resume {
259        Some(id) => {
260            let Some(s) = load(&dir, id) else {
261                return refuse(
262                    CommandStatus::NotFound,
263                    "NOT-FOUND",
264                    &format!("no exploration {id} — see `speccoder spec explore list`"),
265                );
266            };
267            if let Err(why) = core::next_state(&s.state, core::Event::Resume) {
268                return refuse(CommandStatus::UsageError, "EXPLORE-STARTED", why);
269            }
270            let mut input = format!("{}\n\n{}", s.idea, core::carried(&s));
271            if !words.is_empty() {
272                input.push_str(&format!("# More from the person\n\n{words}\n"));
273            }
274            (s, input, "resume")
275        }
276        None => {
277            if words.is_empty() {
278                return refuse(
279                    CommandStatus::UsageError,
280                    "EXPLORE-EMPTY",
281                    "say what to explore: `speccoder spec explore \"<idea>\"`",
282                );
283            }
284            let (name, because) = match select(&root, skill) {
285                Ok(s) => s,
286                Err(e) => return refuse(CommandStatus::NotFound, "NOT-FOUND", &e),
287            };
288            let at = now();
289            let s = ExploreSession {
290                version: 1,
291                id: new_id(),
292                idea: words.to_owned(),
293                skill: name,
294                selected_because: because.to_owned(),
295                state: "open".to_owned(),
296                created_at: at,
297                updated_at: at,
298                runs: Vec::new(),
299                reports: Vec::new(),
300                qa: Vec::new(),
301                choices: Vec::new(),
302                started: None,
303            };
304            (s, words.to_owned(), "explore")
305        }
306    };
307    let (ran, report) = crate::hosting::run_exploring(
308        path,
309        &session.skill,
310        &input,
311        state,
312        provider,
313        secret,
314        options,
315    );
316    let Some(CommandData::Skill(run)) = ran.data() else {
317        return ran;
318    };
319    // Every write is refused while exploring, so a file that changed in
320    // the checkout meanwhile changed outside the run (an editor, another
321    // process): said, never blamed on the skill, and the report stands.
322    let planning = crate::format::at(&root).planning();
323    let wrote: Vec<String> = run
324        .changes
325        .iter()
326        .filter(|(p, _)| !(planning.explore_paths)(p))
327        .map(|(p, _)| p.clone())
328        .collect();
329    let report: Option<ExploreReport> = serde_json::from_value(report).ok();
330    let checked = report.as_ref().map(|r| {
331        if session.skill == FALLBACK {
332            core::validate_fallback(r)
333        } else if r.approaches.is_empty() {
334            Err("no approach to choose from".to_owned())
335        } else {
336            Ok(())
337        }
338    });
339    let ok = matches!(checked, Some(Ok(())));
340    session.runs.push(RunRef {
341        run_id: run.run_id.clone(),
342        kind: kind.to_owned(),
343        words: (kind == "resume" && !words.is_empty()).then(|| words.to_owned()),
344        status: if ok { "completed" } else { "failed" }.to_owned(),
345    });
346    session.qa.extend(answers(&run.tool_calls));
347    if ok && let Some(r) = report {
348        session.reports.push(r);
349        if kind == "resume" {
350            session.state = "open".to_owned();
351        }
352    }
353    session.updated_at = now();
354    if let Err(e) = save(&dir, &session) {
355        return refuse(CommandStatus::Error, "ERROR", &e);
356    }
357    let id = session.id.clone();
358    let skill_name = session.skill.clone();
359    let p = payload(&dir, session);
360    match checked {
361        // Approaches to choose from: the next step is the person's, nothing
362        // is blocked.
363        Some(Ok(())) if !wrote.is_empty() => result(
364            CommandStatus::Ok,
365            &format!(
366                "{} approaches from {skill_name} — choose one: `speccoder spec explore choose N {id}`; {} changed in the checkout during the run, not by it (exploring writes nothing)",
367                p.approaches.len(),
368                wrote.join(", ")
369            ),
370            p,
371        )
372        .with_code("EXPLORE-WROTE"),
373        Some(Ok(())) => result(
374            CommandStatus::Ok,
375            &format!(
376                "{} approaches from {skill_name} — choose one: `speccoder spec explore choose N {id}`",
377                p.approaches.len()
378            ),
379            p,
380        ),
381        Some(Err(why)) => result(
382            CommandStatus::RunFailed,
383            &format!("{skill_name} ended without approaches to choose from: {why}"),
384            p,
385        )
386        .with_code("EXPLORE-INCOMPLETE"),
387        None => result(
388            ran.status(),
389            &format!("{skill_name} ended without its report: {}", ran.message()),
390            p,
391        )
392        .with_code("EXPLORE-INCOMPLETE"),
393    }
394}
395
396/// The person's choice in `session`: approach `n` (1-based) with the
397/// constraints and open questions as they keep and add them. Composes and
398/// stores the brief; no model call.
399#[allow(clippy::too_many_arguments)]
400#[must_use]
401pub fn choose(
402    path: &Path,
403    state: Option<&Path>,
404    id: &str,
405    n: usize,
406    constraints: &[String],
407    questions: &[String],
408    via: Via,
409    basis: &str,
410) -> CommandResult {
411    let Some(state) = state
412        .map(Path::to_path_buf)
413        .or_else(crate::worktree::state_dir)
414    else {
415        return refuse(CommandStatus::Error, "ERROR", "no state directory");
416    };
417    let (root, dir) = match dirs(path, &state) {
418        Ok(d) => d,
419        Err(e) => return *e,
420    };
421    let Some(mut s) = load(&dir, id) else {
422        return refuse(
423            CommandStatus::NotFound,
424            "NOT-FOUND",
425            &format!("no exploration {id} — see `speccoder spec explore list`"),
426        );
427    };
428    let next = match core::next_state(&s.state, core::Event::Choose) {
429        Ok(n) => n,
430        Err(why) => return refuse(CommandStatus::UsageError, "EXPLORE-STARTED", why),
431    };
432    let (approaches, model_constraints, model_questions) = s.on_the_table();
433    let Some(approach) = n.checked_sub(1).and_then(|i| approaches.get(i)) else {
434        return refuse(
435            CommandStatus::UsageError,
436            "USAGE",
437            &format!(
438                "choose 1–{} (approach {n} does not exist)",
439                approaches.len()
440            ),
441        );
442    };
443    // Each kept item keeps its source; what the person added is theirs.
444    let source = |text: &String, from: &[Item]| Item {
445        text: text.trim().to_owned(),
446        source: from
447            .iter()
448            .find(|i| i.text.trim() == text.trim())
449            .map_or_else(|| "person".to_owned(), |i| i.source.clone()),
450    };
451    let kept_c: Vec<Item> = constraints
452        .iter()
453        .filter(|t| !t.trim().is_empty())
454        .map(|t| source(t, &model_constraints))
455        .collect();
456    let kept_q: Vec<Item> = questions
457        .iter()
458        .filter(|t| !t.trim().is_empty())
459        .map(|t| source(t, &model_questions))
460        .collect();
461    let brief = match core::compose(approach, &kept_c, &kept_q, &s.id, |b| {
462        crate::integrity::sha256_hex(b)
463    }) {
464        Ok(b) => b,
465        Err(e) => return refuse(CommandStatus::UsageError, "EXPLORE-BRIEF-LARGE", &e),
466    };
467    if let Err(e) = write_atomic(&brief_path(&dir, &s.id), brief.text.as_bytes()) {
468        return refuse(CommandStatus::Error, "ERROR", &e);
469    }
470    s.choices.push(Choice {
471        approach: n,
472        constraints: kept_c,
473        open_questions: kept_q,
474        by: crate::approvals::who_in(&root),
475        via,
476        basis: basis.to_owned(),
477        at: now(),
478        digest: brief.digest.clone(),
479        bytes: brief.bytes,
480    });
481    next.clone_into(&mut s.state);
482    s.updated_at = now();
483    if let Err(e) = save(&dir, &s) {
484        return refuse(CommandStatus::Error, "ERROR", &e);
485    }
486    let id = s.id.clone();
487    result(
488        CommandStatus::Ok,
489        &format!(
490            "brief {} ready — start the spec from it: `speccoder spec explore start {id}`",
491            brief.digest.get(..12).unwrap_or(&brief.digest)
492        ),
493        payload(&dir, s),
494    )
495}
496
497/// Before a spec starts from `id`'s brief: the stored brief must still be
498/// the one chosen (`digest`), the description it verbatim, the session
499/// not started. The session, and the brief.
500///
501/// # Errors
502///
503/// Why it cannot start, with its code.
504pub fn verify_start(
505    root: &Path,
506    state: &Path,
507    id: &str,
508    digest: &str,
509    description: &str,
510) -> Result<ExploreSession, Box<CommandResult>> {
511    let (_, dir) = dirs(root, state)?;
512    let Some(s) = load(&dir, id) else {
513        return Err(Box::new(refuse(
514            CommandStatus::NotFound,
515            "NOT-FOUND",
516            &format!("no exploration {id} — see `speccoder spec explore list`"),
517        )));
518    };
519    if let Some(started) = &s.started {
520        return Err(Box::new(refuse(
521            CommandStatus::UsageError,
522            "EXPLORE-STARTED",
523            &format!(
524                "{id} already started {} — one brief, one spec; explore again for another",
525                started.unit
526            ),
527        )));
528    }
529    let chosen = match (
530        core::next_state(&s.state, core::Event::Start),
531        s.choices.last(),
532    ) {
533        (Ok(_), Some(c)) => c,
534        _ => {
535            return Err(Box::new(refuse(
536                CommandStatus::UsageError,
537                "EXPLORE-NOT-CHOSEN",
538                &format!("choose an approach first: `speccoder spec explore choose N {id}`"),
539            )));
540        }
541    };
542    let stored = std::fs::read_to_string(brief_path(&dir, id)).unwrap_or_default();
543    let now_digest = core::body_digest(&stored, crate::integrity::sha256_hex);
544    if now_digest != chosen.digest || chosen.digest != digest {
545        return Err(Box::new(refuse(
546            CommandStatus::Error,
547            "EXPLORE-DIGEST",
548            &format!("{id}'s brief changed since it was chosen — choose again to make a new one"),
549        )));
550    }
551    if description != stored {
552        return Err(Box::new(refuse(
553            CommandStatus::UsageError,
554            "EXPLORE-EDITED",
555            "a brief starts a spec verbatim or not at all — edit it by choosing again",
556        )));
557    }
558    Ok(s)
559}
560
561/// Once plan through made the unit from `session`'s brief: its origin,
562/// written once, and the session started.
563pub fn explored(
564    root: &Path,
565    state: &Path,
566    made: &CommandResult,
567    session: &str,
568    digest: &str,
569    via: Via,
570) {
571    let Some(CommandData::PlanningChain(r)) = made.data() else {
572        return;
573    };
574    let Some(unit) = r.unit.as_deref() else {
575        return;
576    };
577    let plane = crate::worktree::project_state_dir(state, root);
578    let _ = crate::ladder::record_origin(
579        &plane,
580        unit,
581        vec![speccoder_core::ladder::Origin::ExploredFrom {
582            session: session.to_owned(),
583            digest: digest.to_owned(),
584            by: crate::approvals::who_in(root),
585            via,
586            at: now(),
587        }],
588    );
589    started(root, state, session, unit);
590}
591
592/// Once the spec exists: the session is started, with its unit.
593pub fn started(root: &Path, state: &Path, id: &str, unit: &str) {
594    let Ok((_, dir)) = dirs(root, state) else {
595        return;
596    };
597    if let Some(mut s) = load(&dir, id) {
598        s.state = "started".to_owned();
599        s.started = Some(core::Started {
600            unit: unit.to_owned(),
601            at: now(),
602        });
603        s.updated_at = now();
604        let _ = save(&dir, &s);
605    }
606}
607
608/// The newest session in `state` ("open" to choose in, "chosen" to start).
609#[must_use]
610pub fn newest(path: &Path, state: &Path, in_state: &str) -> Option<ExploreSession> {
611    let (_, dir) = dirs(path, state).ok()?;
612    sessions(&dir).into_iter().find(|s| s.state == in_state)
613}