1use std::path::Path;
5use std::sync::Arc;
6
7use speccoder_core::snapshot::{Diagnostic, ProjectSnapshot, Severity};
8use speccoder_core::{CommandData, CommandResult, CommandStatus};
9
10use crate::commands::*;
11use crate::{approvals, progress, substrate, verify, worktree};
12
13pub mod acceptance;
14pub mod bug;
15pub mod commit;
16pub mod feature;
17pub mod full;
18pub mod judge;
19pub mod lanes;
20pub mod outcome;
21pub mod samples;
22pub mod story;
23pub mod task;
24
25pub(crate) use acceptance::*;
26pub(crate) use bug::{reject as bug_reject, triad_now as bug_triad_now};
27pub(crate) use commit::*;
28pub use feature::*;
29pub(crate) use judge::*;
30pub(crate) use lanes::*;
31pub(crate) use outcome::*;
32pub(crate) use samples::*;
33pub use task::*;
34
35pub(crate) struct Setup {
37 pub(crate) snapshot: Box<speccoder_core::snapshot::ProjectSnapshot>,
38 pub(crate) model: substrate::model_config::ModelConfig,
39 pub(crate) plane_dir: std::path::PathBuf,
40 pub(crate) format: &'static dyn crate::format::Adapter,
42}
43
44pub(crate) fn bug_spec(path: &Path, feature: Option<&str>) -> Option<String> {
47 let format = crate::format::at(path);
48 format.bug_spec(&format.open(path, feature)?)
49}
50
51pub(crate) fn setup(
52 path: &Path,
53 feature: Option<&str>,
54 state: &Path,
55 command: &str,
56) -> Result<Setup, Box<CommandResult>> {
57 let inspected = inspect(path, feature);
58 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
59 return Err(Box::new(CommandResult::carried(command, &inspected)));
60 };
61 let snapshot = snapshot.clone();
62 preflight(&snapshot, command)?;
63 let config_error =
64 |m: String| Box::new(CommandResult::new(command, CommandStatus::ConfigError, &m));
65 let root = Path::new(&snapshot.root);
66 let config_dir = verify::find_config(root).ok_or_else(|| {
67 config_error(format!(
68 "no speccoder.toml at or above {}; add [model] provider = \"anthropic\", model = \"…\"",
69 root.display()
70 ))
71 })?;
72 let text = std::fs::read_to_string(config_dir.join(verify::config::FILE))
73 .map_err(|e| config_error(format!("speccoder.toml: {e}")))?;
74 let model = substrate::model_config::parse(&text)
75 .map_err(|e| config_error(format!("speccoder.toml: {e}")))?
76 .ok_or_else(|| config_error("speccoder.toml has no [model] section".to_owned()))?;
77 Ok(Setup {
78 format: crate::format::of(&snapshot),
79 plane_dir: worktree::project_state_dir(state, root),
80 snapshot,
81 model,
82 })
83}
84
85fn preflight(snapshot: &ProjectSnapshot, command: &str) -> Result<(), Box<CommandResult>> {
89 if !matches!(command, "run" | "propose") {
90 return Ok(());
91 }
92 let format = crate::format::of(snapshot);
93 let mut extra = Vec::new();
94 if command == "run" {
95 match crate::sandbox::runner_at(Path::new(&snapshot.root)) {
96 Ok((runner, _)) => extra = format.crosscheck(snapshot, &runner),
97 Err(e) => extra.push(Diagnostic::new(
98 Severity::Info,
99 "crosscheck_skipped",
100 format!("cross-check skipped: {e}"),
101 )),
102 }
103 }
104 let problems = format.blocking(snapshot).len()
105 + extra
106 .iter()
107 .filter(|d| d.severity == Severity::Error)
108 .count();
109 if problems == 0 {
110 return Ok(());
111 }
112 let mut shown = snapshot.clone();
113 shown.diagnostics.extend(extra);
114 let name = shown
115 .feature
116 .as_ref()
117 .map(|f| f.feature.name.clone())
118 .unwrap_or_default();
119 let errors: Vec<&Diagnostic> = shown
121 .diagnostics
122 .iter()
123 .filter(|d| d.severity == Severity::Error)
124 .collect();
125 let (said, fix) = match (problems, errors.as_slice()) {
126 (1, [only]) => (only.message.clone(), only.hint.clone()),
127 _ => (format!("{problems} problems"), None),
128 };
129 let result = CommandResult::new(
130 command,
131 CommandStatus::DiagnosticsError,
132 &format!("{} {name} cannot run: {said}", format.wording().unit),
133 );
134 let result = match fix {
135 Some(f) => result.with_fix(vec![format!("→ {f}")]),
136 None => result,
137 };
138 Err(Box::new(
139 result.with_data(CommandData::Inspect(Box::new(shown))),
140 ))
141}
142
143pub(crate) fn tokio_runtime() -> Result<tokio::runtime::Runtime, String> {
144 tokio::runtime::Builder::new_current_thread()
145 .enable_all()
146 .build()
147 .map_err(|e| format!("cannot start the async runtime: {e}"))
148}
149
150pub(crate) fn redact(text: &str, secret: Option<&str>) -> String {
154 let text = crate::secrets::mask(text);
155 match secret {
156 Some(s) if !s.is_empty() => text.replace(s, "[redacted]"),
157 _ => text,
158 }
159}
160
161#[derive(
165 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
166)]
167#[serde(transparent)]
168pub struct RunId(pub String);
169
170impl RunId {
171 #[must_use]
173 pub fn fresh() -> Self {
174 use sha2::{Digest as _, Sha256};
175 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
176 let now = std::time::SystemTime::now()
177 .duration_since(std::time::UNIX_EPOCH)
178 .unwrap_or_default();
179 let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
180 let digest = Sha256::digest(format!(
181 "{}/{}/{}/{n}",
182 now.as_nanos(),
183 std::process::id(),
184 std::thread::current().name().unwrap_or_default()
185 ));
186 let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
187 Self(format!("run-{}-{hex}", now.as_secs()))
188 }
189
190 #[must_use]
192 pub fn as_str(&self) -> &str {
193 &self.0
194 }
195}
196
197impl Default for RunId {
198 fn default() -> Self {
199 Self::fresh()
200 }
201}
202
203impl std::fmt::Display for RunId {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.write_str(&self.0)
206 }
207}
208
209impl RunOptions {
210 #[must_use]
212 pub fn run(&self) -> RunId {
213 self.run_id
214 .lock()
215 .map(|r| r.clone())
216 .unwrap_or_else(|_| RunId(String::new()))
217 }
218
219 #[must_use]
221 pub fn asked_now(&self) -> u32 {
222 self.asked.lock().map(|a| *a).unwrap_or(0)
223 }
224}
225
226#[derive(Debug, Clone, Default)]
228pub struct RunOptions {
229 pub max_tasks: Option<usize>,
231 pub approver: Option<Arc<dyn approvals::Approver>>,
234 pub progress: Option<Arc<dyn progress::Progress>>,
236 pub parallel: usize,
238 pub lane: Option<String>,
240 pub cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
243 pub ignore_checklists: bool,
246 pub fresh_acceptance: bool,
249 pub allow_failing_baseline: bool,
253 pub run_grants: Arc<std::sync::Mutex<Vec<crate::tools::policy::Object>>>,
256 pub run_id: Arc<std::sync::Mutex<RunId>>,
260 pub asked: Arc<std::sync::Mutex<u32>>,
262 pub in_place: bool,
265 pub session: Option<Arc<std::sync::Mutex<crate::in_place::Session>>>,
267 pub bug_home: Option<Arc<bug::NativeBug>>,
269 pub mode: speccoder_core::oracle::RunMode,
273 pub agent: Option<String>,
276 pub cap: Option<crate::substrate::run_config::Cap>,
278 pub left: Option<crate::substrate::run_config::Cap>,
280}
281
282pub(crate) fn workspace(
285 path: &Path,
286 feature: Option<&str>,
287 state: &Path,
288 options: &RunOptions,
289) -> CommandResult {
290 let Some(session) = options
291 .session
292 .as_ref()
293 .and_then(|s| s.lock().ok().map(|s| s.clone()))
294 else {
295 if let Some(e) = crate::in_place::session_elsewhere(path, feature, state) {
296 return e.result("run");
297 }
298 return worktree_with(path, feature, state);
299 };
300 CommandResult::new("worktree", CommandStatus::Ok, "in place").with_data(CommandData::Worktree(
301 speccoder_core::WorktreeReport {
302 feature: session.feature.clone(),
303 path: Some(session.checkout.display().to_string()),
304 project_dir: Some(session.project.display().to_string()),
305 branch: Some(session.branch.clone()),
306 base: Some(session.tip.clone()),
307 created: false,
308 refused_paths: Vec::new(),
309 },
310 ))
311}
312
313pub(crate) fn in_session(
317 command: &str,
318 path: &Path,
319 feature: Option<&str>,
320 state: &Path,
321 options: &RunOptions,
322 f: impl FnOnce(&RunOptions) -> CommandResult,
323) -> CommandResult {
324 if !options.in_place || options.session.is_some() {
325 return f(options);
326 }
327 if options.parallel > 1 {
328 return crate::in_place::InPlaceError {
329 code: "IN-PLACE-PARALLEL",
330 message: "an in-place run has one checkout; it cannot run tasks in parallel".to_owned(),
331 paths: Vec::new(),
332 fix: vec!["speccoder run --in-place (without --parallel)".to_owned()],
333 commands: Vec::new(),
334 }
335 .result(command);
336 }
337 let opened = match crate::format::open(path, feature) {
338 Ok(o) => o,
339 Err(r) => return CommandResult::new(command, r.status, &r.message),
340 };
341 let Some(f_name) = opened
342 .snapshot
343 .feature
344 .as_ref()
345 .map(|f| f.feature.name.clone())
346 else {
347 return CommandResult::new(
348 command,
349 CommandStatus::SelectionNeeded,
350 "no feature selected; pass --feature",
351 );
352 };
353 let root = std::path::PathBuf::from(&opened.snapshot.root);
354 let plane_dir = worktree::project_state_dir(state, &root);
355 let policy = crate::tools::policy::Policy::new(&[], &[]).rules(opened.adapter.rules());
356 let mut session = match crate::in_place::Session::begin(&root, &plane_dir, &f_name, &policy) {
357 Ok(s) => s,
358 Err(e) => return e.result(command),
359 };
360 let format = crate::format::at(&root);
362 if let Some(tasks) = format
363 .inspect(&root, Some(&f_name))
364 .and_then(|snap| snap.feature.and_then(|f| f.tasks))
365 {
366 let file = session.project.join(&tasks.path);
367 session.reconcile(&plane_dir, &file, &tasks, |file, task, hash| {
368 format.tick(file, task, hash)
369 });
370 }
371 let original = session.original.describe();
372 if let Some(p) = &options.progress {
373 p.event(&progress::Event::Notice {
374 text: format!(
375 "in place: on {} in {}; returns to {original} at the end — do not use this checkout until then",
376 session.branch,
377 session.checkout.display()
378 ),
379 });
380 for note in &session.notes {
381 p.event(&progress::Event::Notice { text: note.clone() });
382 }
383 }
384 let shared = Arc::new(std::sync::Mutex::new(session));
385 let inner = RunOptions {
386 session: Some(Arc::clone(&shared)),
387 ..options.clone()
388 };
389 let res = f(&inner);
390 let session = shared.lock().map(|s| s.clone());
391 if let Some(task) = waiting_task(&res) {
394 let notes: String = session
395 .as_ref()
396 .map(|s| s.notes.iter().map(|n| format!("\n{n}")).collect())
397 .unwrap_or_default();
398 let message = format!(
399 "{}{notes}\nthe checkout stays on {} while {task} waits: decide it (`speccoder task approve {task}` or `reject`), then `speccoder run --in-place` continues it — or `speccoder run --in-place --return` gives the checkout back",
400 res.message(),
401 session
402 .as_ref()
403 .map(|s| s.branch.as_str())
404 .unwrap_or("the speccoder branch"),
405 );
406 let status = res.status();
407 return res.reworded(status, &message);
408 }
409 let (back, notes) = match session {
410 Ok(s) => (s.finish(&plane_dir), s.notes),
411 Err(_) => (Ok(()), Vec::new()),
412 };
413 let told: String = notes.iter().map(|n| format!("\n{n}")).collect();
414 match back {
415 Ok(()) => {
416 let message = format!("{} — back on {original}{told}", res.message());
417 let status = res.status();
418 res.reworded(status, &message)
419 }
420 Err(e) => {
421 let message = format!("{}{told}\n{}", res.message(), e.line());
422 res.reworded(CommandStatus::NeedsHuman, &message)
423 .with_code(e.code)
424 .with_fix(e.fix.clone())
425 }
426 }
427}
428
429fn waiting_task(res: &CommandResult) -> Option<String> {
431 let waits = |r: &speccoder_core::RunReport| {
432 r.pending
433 .as_ref()
434 .is_some_and(|p| p["waiting"] == true)
435 .then(|| r.task.clone())
436 };
437 match res.data() {
438 Some(CommandData::Run(r)) => waits(r),
439 Some(CommandData::Feature(f)) => f.attempts.last().and_then(waits),
440 _ => None,
441 }
442}
443
444pub(crate) fn checklist_gate(
448 project: &Path,
449 feature: Option<&str>,
450 ignore: bool,
451) -> Result<bool, Box<CommandResult>> {
452 let format = crate::format::at(project);
453 let Some(snap) = format.inspect(project, feature) else {
454 return Ok(false);
455 };
456 let Some(f) = snap.feature.as_ref() else {
457 return Ok(false);
458 };
459 let lists = format.checklists(Path::new(&snap.root), &f.feature.dir);
460 let open: Vec<String> = lists
461 .iter()
462 .filter(|c| c.open > 0)
463 .map(|c| format!("{} ({} of {} open)", c.file, c.open, c.total))
464 .collect();
465 if open.is_empty() {
466 return Ok(false);
467 }
468 if ignore {
469 return Ok(true);
470 }
471 Err(Box::new(
472 CommandResult::new(
473 "run",
474 CommandStatus::NeedsHuman,
475 &(format.messages().checklists)(&open.join(", ")),
476 )
477 .with_data(CommandData::Checklists(lists)),
478 ))
479}
480
481pub(crate) fn stage(
484 live: &dyn crate::progress::Progress,
485 task: &str,
486 stage: speccoder_core::stage::Stage,
487 state: speccoder_core::stage::StageState,
488 detail: Option<String>,
489 since: Option<std::time::Instant>,
490) {
491 stage_with_tail(live, task, stage, state, detail, since, Vec::new());
492}
493
494pub(crate) fn stage_with_tail(
496 live: &dyn crate::progress::Progress,
497 task: &str,
498 stage: speccoder_core::stage::Stage,
499 state: speccoder_core::stage::StageState,
500 detail: Option<String>,
501 since: Option<std::time::Instant>,
502 tail: Vec<String>,
503) {
504 live.event(&crate::progress::Event::Stage {
505 task: task.to_owned(),
506 stage,
507 state,
508 detail,
509 elapsed_ms: since.map(|t| u64::try_from(t.elapsed().as_millis()).unwrap_or(u64::MAX)),
510 tail,
511 });
512}
513
514pub(crate) fn failing_tail(report: &speccoder_core::verify::VerifyReport) -> Vec<String> {
516 report
517 .checks
518 .iter()
519 .find(|c| c.status.is_failure())
520 .map(|c| crate::progress::tail(&format!("{}\n{}", c.stdout_tail, c.stderr_tail)))
521 .unwrap_or_default()
522}