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