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