1use 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#[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
42fn 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 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#[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#[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
183fn 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
200fn 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#[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#[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 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 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#[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 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
497pub 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
561pub 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
592pub 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#[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}