1use std::path::Path;
4use std::sync::Arc;
5
6use speccoder_core::stage::{Stage, StageState};
7use speccoder_core::{CommandData, CommandResult, CommandStatus};
8
9use crate::pipeline::*;
10use crate::{
11 approvals, attempts, external, git, integrity, mcp, mutation, progress, resume, sandbox,
12 substrate, tools, verify, worktree,
13};
14
15#[must_use]
18pub fn run(path: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
19 let Some(state) = worktree::state_dir() else {
20 return CommandResult::new(
21 "run",
22 CommandStatus::ConfigError,
23 "no state directory (set SPECCODER_STATE_DIR or HOME)",
24 )
25 .with_code("NO-STATE-DIR");
26 };
27 let model = match setup(path, feature, &state, "run") {
28 Ok(s) => s.model,
29 Err(r) => return *r,
30 };
31 let (driver, key) = match substrate::model_config::open_driver(&model) {
32 Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
33 Err(e) => return substrate::model_config::refused("run", &e),
34 };
35 match driver {
36 Ok(provider) => run_task(path, feature, task, &state, provider, Some(&key), options),
37 Err(e) => CommandResult::new("run", CommandStatus::ConfigError, &redact(&e, Some(&key))),
38 }
39}
40
41pub fn run_with(
43 path: &Path,
44 feature: Option<&str>,
45 task: &str,
46 state: &Path,
47 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
48 secret: Option<&str>,
49) -> CommandResult {
50 run_task(
51 path,
52 feature,
53 task,
54 state,
55 provider,
56 secret,
57 &RunOptions::default(),
58 )
59}
60
61#[must_use]
63pub fn run_with_options(
64 path: &Path,
65 feature: Option<&str>,
66 task: &str,
67 state: &Path,
68 provider: Arc<dyn agentplane::model::ModelProvider>,
69 secret: Option<&str>,
70 options: &RunOptions,
71) -> CommandResult {
72 run_task(path, feature, task, state, provider, secret, options)
73}
74
75#[must_use]
77pub fn run_with_approver(
78 path: &Path,
79 feature: Option<&str>,
80 task: &str,
81 state: &Path,
82 provider: Arc<dyn agentplane::model::ModelProvider>,
83 secret: Option<&str>,
84 approver: Arc<dyn approvals::Approver>,
85) -> CommandResult {
86 let options = RunOptions {
87 approver: Some(approver),
88 ..RunOptions::default()
89 };
90 run_task(path, feature, task, state, provider, secret, &options)
91}
92
93#[must_use]
96pub fn run_native_bug(
97 root: &Path,
98 bug: &speccoder_core::bug::BugReport,
99 state: &Path,
100 provider: Arc<dyn agentplane::model::ModelProvider>,
101 secret: Option<&str>,
102 options: &RunOptions,
103) -> CommandResult {
104 let options = RunOptions {
105 bug_home: Some(Arc::new(super::bug::NativeBug::of(bug))),
106 ..options.clone()
107 };
108 run_task(root, None, &bug.task, state, provider, secret, &options)
109}
110
111#[allow(clippy::too_many_lines)] pub(crate) fn run_task(
113 path: &Path,
114 feature: Option<&str>,
115 task: &str,
116 state: &Path,
117 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
118 secret: Option<&str>,
119 options: &RunOptions,
120) -> CommandResult {
121 use speccoder_core::RunReport;
122 if options.in_place && options.session.is_none() {
124 return in_session("run", path, feature, state, options, |o| {
125 run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
126 });
127 }
128 let approver = options.approver.clone();
129 use speccoder_core::snapshot::TaskState;
130
131 let refuse =
132 |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
133
134 let s = match setup(path, feature, state, "run") {
136 Ok(s) => s,
137 Err(r) => return *r,
138 };
139 let native = options.bug_home.clone();
141 let feature_name = match (&native, s.snapshot.feature.as_ref()) {
142 (Some(n), _) => n.unit.clone(),
143 (None, Some(f)) => f.feature.name.clone(),
144 (None, None) => {
145 return refuse(
146 CommandStatus::SelectionNeeded,
147 "no feature selected; pass --feature".to_owned(),
148 );
149 }
150 };
151 let plane_dir = match &options.lane {
154 Some(lane) => s.plane_dir.join("lanes").join(lane),
155 None => s.plane_dir.clone(),
156 };
157 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
159 &plane_dir,
160 s.model.prices,
161 options.progress.clone(),
162 ));
163
164 let wt = match &native {
166 Some(n) => crate::commands::worktree_of(&s.snapshot, &n.unit, &n.dir, state),
167 None => workspace(path, Some(&feature_name), state, options),
168 };
169 let Some(CommandData::Worktree(w)) = wt.data() else {
170 return CommandResult::carried("run", &wt);
171 };
172 if wt.status() != CommandStatus::Ok {
173 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
174 }
175 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
176 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
177 };
178 let (wt_path, project_dir) = match &options.lane {
179 None => (wt_path, project_dir),
180 Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
181 {
182 Ok(paths) => paths,
183 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
184 },
185 };
186 let project = std::path::PathBuf::from(&project_dir);
187 let wt_root = std::path::PathBuf::from(&wt_path);
188 super::bisect::prune(&wt_root, &s.plane_dir.join("tmp"));
190 let tmp = plane_dir.join("tmp");
191
192 let elsewhere = if options.lane.is_none() {
198 match crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task) {
199 Ok(e) => e,
200 Err(e) => {
201 return refuse(
202 CommandStatus::Error,
203 format!(
204 "the calls waiting for a person could not be read, so whether another task's attempt holds the worktree is unknown: {e}"
205 ),
206 );
207 }
208 }
209 } else {
210 crate::approvals::Elsewhere::default()
211 };
212 if let Some((other, covers)) = elsewhere.waiting {
213 return refuse(
214 CommandStatus::NeedsHuman,
215 format!(
216 "{other} waits for a person ({covers}) and its attempt holds the worktree: decide it (`speccoder task approve {other}` or `reject`), then `speccoder run --task {other}`"
217 ),
218 );
219 }
220 if options.lane.is_none()
222 && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
223 {
224 return refuse(
225 CommandStatus::NeedsHuman,
226 format!(
227 "{other}'s acceptance tests reject an implementation the spec appears to allow, and its attempt holds the worktree: `speccoder task rule {other} --tests-right --reason …` or `speccoder run --task {other} --fresh-acceptance`"
228 ),
229 )
230 .with_code("TESTS-OVER-STRICT");
231 }
232 let commits_per_task = verify::find_config(&project)
236 .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
237 .and_then(|t| substrate::run_config::parse(&t).ok())
238 .is_none_or(|c| c.commit_per_task);
239 let marker_path = resume::path(&plane_dir, &feature_name, task);
242 if options.fresh_acceptance && options.lane.is_none() {
245 let _ = std::fs::remove_file(&marker_path);
246 crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
247 }
248 let head_now = git::Git::at(&wt_root)
249 .line(&["rev-parse", "HEAD"])
250 .unwrap_or_default();
251 let autopilot = options.mode == speccoder_core::oracle::RunMode::Autopilot;
256 let interrupted = resume::load(&marker_path).filter(|m| {
257 m.head == head_now && options.lane.is_none() && !head_now.is_empty() && !autopilot
258 });
259 if let Some(m) = &interrupted {
262 if let (Some(id), Ok(mut run)) = (&m.run_id, options.run_id.lock()) {
263 *run = crate::pipeline::RunId(id.clone());
264 }
265 if let Ok(mut asked) = options.asked.lock() {
266 *asked = (*asked).max(m.asked);
267 }
268 if let Ok(mut g) = options.run_grants.lock() {
269 for o in &m.run_grants {
270 if !g.contains(o) {
271 g.push(o.clone());
272 }
273 }
274 }
275 }
276 let marker_guard = resume::Guard::new(marker_path.clone());
277 let resumed_from = interrupted.as_ref().map(|_| {
280 std::fs::read_to_string(plane_dir.join(progress::FILE))
281 .ok()
282 .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
283 .filter(|l| l.task == task)
284 .map_or((None, None), |l| (l.stage, l.last_call))
285 });
286 let mut set_aside = None;
287 if interrupted.is_none()
289 && commits_per_task
290 && options.session.is_none()
291 && worktree::require_clean(&wt_root).is_err()
292 {
293 let left = worktree::leftovers(&wt_root);
297 if !left.is_empty() {
298 let mut what = Vec::new();
299 if !left.commit.is_empty() {
300 what.push(format!("{} is not committed", left.commit.join(", ")));
301 }
302 if !left.ignore.is_empty() {
303 what.push(format!("{} is not git-ignored", left.ignore.join(", ")));
304 }
305 let checkout = std::path::Path::new(&s.snapshot.root);
306 let remedy = crate::code::setup_remedy(checkout, Some(&feature_name));
307 let command = remedy
308 .as_ref()
309 .map_or_else(|| left.fix(checkout, &wt_root, &[]), |r| r.command.clone());
310 return refuse(
311 CommandStatus::GitPrecondition,
312 format!(
313 "{} — the checks write it on every run, so each run would find it in the worktree and set it aside",
314 what.join(" and "),
315 ),
316 )
317 .with_code("SETUP-INCOMPLETE")
318 .with_fix(vec!["speccoder fix".to_owned(), command])
319 .with_remedy(remedy);
320 }
321 match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
322 Ok(reference) => set_aside = Some(reference),
323 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
324 }
325 }
326
327 let format = crate::format::at(&project);
329 let Some(snap) = format.inspect(
330 &project,
331 if native.is_some() {
332 None
333 } else {
334 Some(&feature_name)
335 },
336 ) else {
337 return refuse(
338 CommandStatus::NotAProject,
339 format.messages().no_project_in_worktree.to_owned(),
340 );
341 };
342 if native.is_some()
343 && crate::bug::state::load(&s.plane_dir, &feature_name, task)
344 .is_some_and(|b| b.outcome == speccoder_core::bug::Outcome::Fixed)
345 {
346 return refuse(
347 CommandStatus::TaskNotReady,
348 format!("{task} is already fixed"),
349 )
350 .with_code("TASK-DONE");
351 }
352 if native.is_none() {
355 let Some(st) = snap
356 .feature
357 .as_ref()
358 .and_then(|f| f.schedule.as_ref())
359 .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
360 else {
361 return refuse(
362 CommandStatus::NotFound,
363 format!("no task {task} in {feature_name}"),
364 );
365 };
366 match st.state {
367 TaskState::Done => {
368 return refuse(
369 CommandStatus::TaskNotReady,
370 format!("{task} is already done"),
371 )
372 .with_code("TASK-DONE");
373 }
374 TaskState::Blocked => {
375 return refuse(
376 CommandStatus::TaskNotReady,
377 format!("{task} is blocked by {}", st.blocked_by.join(", ")),
378 )
379 .with_code("TASK-BLOCKED");
380 }
381 TaskState::Ready => {}
382 }
383 }
384 let checklists_overridden = if native.is_some() {
387 false
388 } else {
389 match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
390 Ok(overridden) => overridden,
391 Err(stop) => return *stop,
392 }
393 };
394 let parsed_task = snap
395 .feature
396 .as_ref()
397 .and_then(|f| f.tasks.as_ref())
398 .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
399 let tasks_file = snap
400 .feature
401 .as_ref()
402 .and_then(|f| f.tasks.as_ref())
403 .map(|t| project.join(&t.path));
404
405 let bundle = match native
406 .as_ref()
407 .map(|n| n.bundle(&project))
408 .map_or_else(|| format.bundle(&snap, task), Ok)
409 {
410 Ok(b) => b,
411 Err(_) => {
412 return refuse(
413 CommandStatus::NotFound,
414 format!("no task {task} in the worktree's {feature_name}"),
415 );
416 }
417 };
418 let acceptance_story = native
421 .is_none()
422 .then(|| crate::acceptance_at::story_at(&s.plane_dir, &feature_name, task))
423 .flatten();
424 let story_task: Option<String> = acceptance_story.as_ref().and_then(|st| {
425 snap.feature
426 .as_ref()
427 .and_then(|f| f.tasks.as_ref())
428 .and_then(|t| {
429 t.tasks
430 .iter()
431 .find(|t| t.story.as_deref() == Some(st.as_str()))
432 })
433 .map(|t| t.id.clone())
434 });
435 let bundle = match (&acceptance_story, &story_task) {
436 (Some(st), Some(other)) => {
437 let mut b = bundle;
438 if let Ok(of) = format.bundle(&snap, other) {
439 b.story = of.story;
440 }
441 b.task.story.clone_from(st);
442 b
443 }
444 _ => bundle,
445 };
446 let Some(config_root) = verify::find_config(&project) else {
447 return refuse(
448 CommandStatus::ConfigError,
449 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
450 )
451 .with_code("CONFIG-MISSING");
452 };
453 let config_text =
454 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
455 let run_config = match substrate::run_config::parse(&config_text) {
456 Ok(c) => c,
457 Err(e) => {
458 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
459 .with_code("CONFIG-INVALID");
460 }
461 };
462 if run_config.review_required
465 && let Some(f) = snap.feature.as_ref()
466 && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
467 && !t.complete()
468 {
469 return refuse(
470 CommandStatus::NeedsHuman,
471 format!(
472 "{feature_name}'s spec is not approved yet ({} approved; {} to review, {} with changes requested, {} changed since, {} open question(s); committed review only) and [review] required is on",
473 t.approved, t.open, t.changes_requested, t.lapsed, t.questions
474 ),
475 )
476 .with_code("REVIEW-PENDING");
477 }
478 if run_config.review_required {
481 let ambiguous = match crate::oracle::open_ambiguities(
482 &s.plane_dir,
483 &feature_name,
484 &format.oracle_requirements(&snap),
485 ) {
486 Ok(a) => a,
487 Err(t) => {
488 return refuse(
489 CommandStatus::NeedsHuman,
490 format!(
491 "{}: whether it is read two ways is unknown and [review] required is on",
492 t.0
493 ),
494 )
495 .with_code("ORACLE-TAMPERED");
496 }
497 };
498 if let Some((req, scenario)) = ambiguous.first() {
499 return refuse(
500 CommandStatus::NeedsHuman,
501 format!(
502 "{req} is read two ways ({scenario}) and [review] required is on: choose a reading or reword it first"
503 ),
504 )
505 .with_code("REVIEW-AMBIGUOUS")
506 .with_fix(vec![
507 format!("speccoder review tests {req} --reading A {scenario}"),
508 format!("speccoder review tests {req} --reading B {scenario}"),
509 ]);
510 }
511 }
512 let executor = match external::parse(&config_text) {
513 Ok(e) => e,
514 Err(e) => {
515 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
516 .with_code("CONFIG-INVALID");
517 }
518 };
519 let sandbox_config = match sandbox::parse(&config_text) {
521 Ok(c) => c,
522 Err(e) => {
523 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
524 .with_code("CONFIG-INVALID");
525 }
526 };
527 let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
528 Ok(r) => r,
529 Err(e) => return refuse(CommandStatus::ConfigError, e),
530 };
531 if options.mode == speccoder_core::oracle::RunMode::Autopilot && !runner.is_sandboxed() {
533 return refuse(
534 CommandStatus::ConfigError,
535 "autopilot needs the OS sandbox, and commands here would run unsandboxed".to_owned(),
536 )
537 .with_code("SANDBOX-UNAVAILABLE");
538 }
539 let mutation_config = match mutation::parse(&config_text) {
540 Ok(c) => c,
541 Err(e) => {
542 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
543 .with_code("CONFIG-INVALID");
544 }
545 };
546 let check_argvs = verify::check_argvs(&config_root);
547 let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow)
548 .with_prefixes(run_config.allow_prefix.clone())
549 .rules(format.rules());
550 let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
553 {
554 Some(s) => s.confine(policy, runner),
555 None => (policy, runner, None),
556 };
557 let sandbox_notice = sandbox_notice.or(carve_notice);
558 if let Some(e) = options.session.as_ref().and_then(|s| {
561 s.lock().ok().and_then(|s| {
562 if interrupted.is_some() {
564 s.check_before_tick(&policy).err()
565 } else {
566 s.check(&policy).err()
567 }
568 })
569 }) {
570 return e.result("run");
571 }
572
573 let wt_root_canonical = std::fs::canonicalize(&wt_root)
574 .unwrap_or_else(|_| wt_root.clone())
575 .display()
576 .to_string();
577
578 let this_run = options.run();
581 let (decisions, task_grants) = match approvals::Decisions::load(&s.plane_dir) {
582 Ok(mut d) => {
583 if !autopilot
586 && d.claim(&feature_name, task, this_run.as_str())
587 && let Err(e) = d.save(&s.plane_dir)
588 {
589 return refuse(CommandStatus::Error, e);
590 }
591 (
592 d.for_task(&feature_name, task),
593 if autopilot {
594 Vec::new()
595 } else {
596 d.for_run(&feature_name, task, this_run.as_str())
597 },
598 )
599 }
600 Err(e) => return refuse(CommandStatus::ConfigError, e),
601 };
602
603 if let Some((stage, last_call)) = &resumed_from {
604 live.event(&progress::Event::Resumed {
605 task: task.to_owned(),
606 stage: *stage,
607 last_call: last_call.clone(),
608 });
609 }
610 live.event(&progress::Event::TaskStarted {
611 feature: feature_name.clone(),
612 task: task.to_owned(),
613 title: parsed_task
614 .as_ref()
615 .map(|t| t.title.clone())
616 .unwrap_or_default(),
617 });
618 let fail = |status: CommandStatus, m: String| {
620 live.event(&progress::Event::TaskFinished {
621 task: task.to_owned(),
622 outcome: "error".to_owned(),
623 message: redact(&m, secret),
624 run: None,
625 input_tokens: 0,
626 output_tokens: 0,
627 cache_read_tokens: 0,
628 cache_write_tokens: 0,
629 usd: None,
630 regression: None,
631 });
632 refuse(status, m)
633 };
634 let began = std::time::Instant::now();
636 stage(
637 &*live,
638 task,
639 Stage::Baseline,
640 StageState::Started,
641 None,
642 None,
643 );
644 let baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
645 Ok(b) => b,
646 Err(e) => {
647 stage(
648 &*live,
649 task,
650 Stage::Baseline,
651 StageState::Failed,
652 None,
653 Some(began),
654 );
655 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
656 }
657 };
658 if !options.allow_failing_baseline
663 && let Some((c, why)) = baseline
664 .checks
665 .iter()
666 .find_map(|c| broken(c).map(|w| (c, w)))
667 {
668 stage(
669 &*live,
670 task,
671 Stage::Baseline,
672 StageState::Failed,
673 Some(format!("`{}` cannot run", c.name)),
674 Some(began),
675 );
676 let checkout = std::path::Path::new(&s.snapshot.root);
679 if let Some(remedy) = crate::code::setup_remedy(checkout, Some(&feature_name)) {
680 return fail(
681 CommandStatus::GitPrecondition,
682 format!(
683 "check `{}` cannot run in the worktree: your checkout has not committed its setup — {why}",
684 c.name
685 ),
686 )
687 .with_code("SETUP-INCOMPLETE")
688 .with_fix(vec!["speccoder fix".to_owned(), remedy.command.clone()])
689 .with_remedy(Some(remedy));
690 }
691 return fail(
692 CommandStatus::ConfigError,
693 if why.contains("git add") || why.contains("--allow-failing-baseline") {
696 format!(
697 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why}",
698 c.name
699 )
700 } else {
701 format!(
702 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why} — fix the check (a file it needs may be uncommitted: `git status`), or run with --allow-failing-baseline",
703 c.name
704 )
705 },
706 )
707 .with_code("CHECK-BROKEN");
708 }
709 let failing = baseline
711 .checks
712 .iter()
713 .filter(|c| c.status.is_failure())
714 .count();
715 stage(
716 &*live,
717 task,
718 Stage::Baseline,
719 StageState::Passed,
720 (failing > 0).then(|| format!("{failing} check(s) already failing")),
721 Some(began),
722 );
723
724 let rt = match tokio_runtime() {
725 Ok(rt) => rt,
726 Err(e) => return fail(CommandStatus::RunFailed, e),
727 };
728 let bundle_value = match serde_json::to_value(&bundle) {
729 Ok(v) => v,
730 Err(e) => return fail(CommandStatus::Error, e.to_string()),
731 };
732 let instructions = crate::instructions::collect(
736 &wt_root,
737 &project,
738 &speccoder_core::scope::named_paths(&bundle.task.title),
739 &run_config.instructions,
740 );
741 let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
742 &instructions
743 } else {
744 &[]
745 };
746
747 let servers = match mcp::parse(&config_text) {
749 Ok(s) => s,
750 Err(e) => {
751 return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
752 .with_code("CONFIG-INVALID");
753 }
754 };
755 let mut connected = if servers.is_empty() {
756 mcp::Connected::default()
757 } else {
758 live.event(&progress::Event::Notice {
759 text: format!("starting {} MCP server(s)", servers.len()),
760 });
761 rt.block_on(mcp::connect(&servers, &project, &runner))
762 };
763 let approved_tools: Vec<String> = task_grants
765 .iter()
766 .filter_map(|g| g.tool_name().map(str::to_owned))
767 .collect();
768 let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
769 connected.notices.extend(pinned);
770
771 let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
774 Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
775 Err(e) => return fail(CommandStatus::Error, e.to_string()),
776 };
777 let swept =
780 match crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc()) {
781 Ok(p) => p,
782 Err(e) => {
783 return fail(
784 CommandStatus::RunFailed,
785 format!("the calls past their deadline could not be denied: {e}"),
786 );
787 }
788 };
789 let present = crate::approvals::PresentContext {
791 root: None,
792 sandbox: runner.is_sandboxed().then(|| {
793 if cfg!(target_os = "macos") {
794 "Seatbelt".to_owned()
795 } else {
796 "Landlock+seccomp".to_owned()
797 }
798 }),
799 network: match sandbox_config.network {
800 sandbox::Network::Off => "none",
801 sandbox::Network::Loopback => "loopback",
802 sandbox::Network::On => "unrestricted",
803 }
804 .to_owned(),
805 file_text: None,
806 };
807 let plane = match rt.block_on(async {
808 substrate::Plane::open_with(
809 &plane_dir,
810 &s.model,
811 provider,
812 Some(tools_server),
813 substrate::Hooks {
814 progress: Some(Arc::clone(&live)),
815 mcp: connected.clients.clone(),
816 mcp_tools: connected.tools.clone(),
817 format: Some(format),
818 present: present.clone(),
819 },
820 )
821 }) {
822 Ok(p) => p,
823 Err(e) => return fail(CommandStatus::RunFailed, e.0),
824 };
825 let mut notices: Vec<String> = elsewhere
826 .replaced_journal
827 .iter()
828 .chain(swept.iter())
829 .chain(plane.replaced_journal.iter())
830 .map(|p| {
831 format!(
832 "the journal could not be read by this build and was moved to {}; past runs stay in git",
833 p.display()
834 )
835 })
836 .chain(connected.notices.iter().cloned())
837 .chain(sandbox_notice.clone())
838 .chain(resumed_from.as_ref().map(|(stage, last_call)| {
839 format!(
840 "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
841 stage.map_or("a run", speccoder_core::stage::Stage::name),
842 last_call
843 .as_ref()
844 .map(|c| format!(" ({c})"))
845 .unwrap_or_default()
846 )
847 }))
848 .collect();
849
850 let demand_red = run_config.acceptance && format.is_convergence(&bundle.task.phase);
853 let start = match &interrupted {
859 Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
860 Ok(tree) => worktree::Checkpoint(tree),
861 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
862 },
863 None => match worktree::checkpoint(&wt_root, &tmp) {
864 Ok(c) => c,
865 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
866 },
867 };
868
869 let began = std::time::Instant::now();
870 let bug_ctx = super::bug::Ctx {
875 rt: &rt,
876 plane: &plane,
877 project: &project,
878 wt_root: &wt_root,
879 wt_path: &wt_path,
880 tmp: &tmp,
881 policy: &policy,
882 runner: &runner,
883 state: &s.plane_dir,
884 feature: &feature_name,
885 task,
886 bundle: &bundle_value,
887 instructions: &instructions,
888 live: &*live,
889 approver: approver.as_deref(),
890 held_out: run_config.held_out,
891 max_tries: run_config.reproduce_tries,
892 bug_test: run_config.bug_test.as_deref(),
893 start: &start,
894 };
895 let mut bug_fix = match super::bug::phase(&s.plane_dir, &feature_name, task, &project, format) {
896 Err(e) => return fail(CommandStatus::NeedsHuman, e),
897 Ok(None) => None,
898 Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
899 Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
900 match super::bug::reproduce(&bug_ctx, *b) {
901 Ok(f) => Some(f),
902 Err(stop) => return *stop,
903 }
904 }
905 Ok(Some(super::bug::Phase::Reproduce(_))) => None,
906 };
907 let bug_candidate = bug_fix.as_ref().and_then(|f| {
908 f.bug.candidate.clone().and_then(|c| {
909 crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
910 .candidate_patch
911 .map(|d| (d, c))
912 })
913 });
914 let test_layout = crate::layout::detect(&project, &check_argvs);
917 let test_layout_text = test_layout.text();
918 let story_files = super::commit::story_files(&wt_root, &feature_name);
919 let scenarios = match &bug_fix {
920 Some(f) => super::bug::scenarios(&f.bug),
921 None => format.scenarios(&snap, story_task.as_deref().unwrap_or(task)),
922 };
923 let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
926 let mut review_reuse = None;
927 if bug_fix.is_none() && run_config.acceptance && !options.fresh_acceptance {
928 match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
929 Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
930 Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
931 "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
932 )),
933 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
934 notices.push(format!("{req}: frozen oracle not present here — authoring"))
935 }
936 Some(crate::oracle::TaskReuse::Lapsed {
937 req,
938 frozen,
939 current,
940 }) => {
941 if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
942 live.event(&progress::Event::Oracle {
943 step: progress::OracleEvent::Lapsed {
944 unit: feature_name.clone(),
945 requirement: req.clone(),
946 frozen_wording_sha256: frozen,
947 current_wording_sha256: current,
948 },
949 });
950 }
951 notices.push(format!(
952 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
953 ));
954 }
955 Some(crate::oracle::TaskReuse::Tampered(why)) => {
956 return fail(
957 CommandStatus::NeedsHuman,
958 format!("{why}: the task stops for a person, nothing is ticked"),
959 )
960 .with_code("ORACLE-TAMPERED");
961 }
962 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
963 return fail(
964 CommandStatus::NeedsHuman,
965 format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
966 )
967 .with_code("REVIEW-UNREADABLE");
968 }
969 None => {}
970 }
971 }
972 let mut acceptance = if let Some(prepared) = interrupted
973 .as_ref()
974 .and_then(|m| m.acceptance.clone())
975 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
976 {
977 let mut prepared = prepared;
981 if let Some(sealed) = bug_fix
982 .as_ref()
983 .and_then(|f| f.record.held_out_sealed.as_ref())
984 {
985 prepared.held_out = Some(sealed.manifest_sha256.clone());
986 prepared.report["held_out"] = serde_json::json!({
987 "state": "on",
988 "manifest": format!("sha256:{}", sealed.manifest_sha256),
989 "kept": sealed.count,
990 });
991 }
992 Some(prepared)
993 } else if let Some(f) = &bug_fix {
994 stage(&*live, task, Stage::Red, StageState::Started, None, None);
995 match super::bug::reuse(&bug_ctx, f) {
996 Ok(a) => Some(a),
997 Err(e) => {
998 let _ = worktree::restore(&wt_root, &tmp, &start);
999 stage(
1000 &*live,
1001 task,
1002 Stage::Red,
1003 StageState::Failed,
1004 Some(e.clone()),
1005 Some(began),
1006 );
1007 return fail(CommandStatus::NeedsHuman, e);
1008 }
1009 }
1010 } else if let Some(reuse) = review_reuse.take() {
1011 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1014 let crate::oracle::TaskReuse::Ready {
1015 record,
1016 files,
1017 tests,
1018 manifest,
1019 } = reuse
1020 else {
1021 return fail(CommandStatus::Error, "no frozen oracle".to_owned());
1022 };
1023 match reuse_pre_frozen(
1024 &project,
1025 &record,
1026 &files,
1027 &tests,
1028 &runner,
1029 RedRule::RunTime,
1030 Some(&SealedCheck {
1031 state: &s.plane_dir,
1032 unit: &feature_name,
1033 wt_root: &wt_root,
1034 tmp: &tmp,
1035 policy: &policy,
1036 }),
1037 ) {
1038 Ok(mut a) => {
1039 a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
1040 notices.push(format!(
1041 "acceptance tests frozen at review by {} ({}) for {}: no author call",
1042 record.decided.by, record.decided.via, record.requirement
1043 ));
1044 review_frozen = Some((*record, manifest));
1045 Some(a)
1046 }
1047 Err(e) => {
1048 let _ = worktree::restore(&wt_root, &tmp, &start);
1049 stage(
1050 &*live,
1051 task,
1052 Stage::Red,
1053 StageState::Failed,
1054 Some(e.clone()),
1055 Some(began),
1056 );
1057 return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
1058 }
1059 }
1060 } else if run_config.acceptance && format.acceptance_testable(&bundle) {
1061 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1062 Some(prepare_acceptance(&AcceptanceSetup {
1063 rt: &rt,
1064 plane: &plane,
1065 project: &project,
1066 worktree: &wt_root,
1067 tmp: &tmp,
1068 policy: &policy,
1069 feature: &feature_name,
1070 task,
1071 bundle: bundle_value.clone(),
1072 scenarios: &scenarios,
1073 instructions: &instructions,
1074 runner: &runner,
1075 layout: Some(&test_layout),
1076 story_files: &story_files,
1077 }))
1078 } else {
1079 None
1080 };
1081 if review_frozen.is_none()
1082 && acceptance
1083 .as_ref()
1084 .is_some_and(|a| a.report["source"] == "review")
1085 && let Some(crate::oracle::TaskReuse::Ready {
1086 record, manifest, ..
1087 }) = review_reuse.take()
1088 {
1089 review_frozen = Some((*record, manifest));
1090 }
1091 let held_out_store = match (&bug_fix, &review_frozen) {
1094 (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1095 (None, Some((r, _))) => {
1096 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1097 }
1098 (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1099 };
1100 if let Some(a) = acceptance
1101 .as_mut()
1102 .filter(|a| a.report.get("held_out").is_none())
1103 {
1104 if !run_config.held_out {
1105 a.report["held_out"] = serde_json::json!({ "state": "off" });
1106 } else if a.argv.is_none() {
1107 a.report["held_out"] =
1108 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1109 } else if !a.red {
1110 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1111 } else if scenarios.is_empty() {
1112 a.report["held_out"] =
1113 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1114 } else {
1115 let (held, sha) = prepare_held_out(
1116 &AcceptanceSetup {
1117 rt: &rt,
1118 plane: &plane,
1119 project: &project,
1120 worktree: &wt_root,
1121 tmp: &tmp,
1122 policy: &policy,
1123 feature: &feature_name,
1124 task,
1125 bundle: bundle_value.clone(),
1126 scenarios: &scenarios,
1127 instructions: &instructions,
1128 runner: &runner,
1129 layout: Some(&test_layout),
1130 story_files: &story_files,
1131 },
1132 a,
1133 &scenarios,
1134 &held_out_store,
1135 story_checkpoint(
1136 snap.feature.as_ref().and_then(|f| f.tasks.as_ref()),
1137 task,
1138 &scenarios,
1139 ),
1140 );
1141 a.report["held_out"] = held;
1142 a.held_out = sha;
1143 }
1144 }
1145 if run_config.held_out && !run_config.acceptance {
1146 notices.push(
1147 "[acceptance] held_out has no effect while generate = false: no held-out tests"
1148 .to_owned(),
1149 );
1150 }
1151 match &acceptance {
1152 None => stage(
1153 &*live,
1154 task,
1155 Stage::Red,
1156 StageState::Skipped,
1157 Some("no acceptance examples".to_owned()),
1158 None,
1159 ),
1160 Some(a) if a.red => stage(
1161 &*live,
1162 task,
1163 Stage::Red,
1164 StageState::Passed,
1165 a.red_class.map(class_word),
1166 Some(began),
1167 ),
1168 Some(a) => stage(
1169 &*live,
1170 task,
1171 Stage::Red,
1172 StageState::Failed,
1173 Some(a.red_class.map_or_else(
1174 || "not demonstrated".to_owned(),
1175 |c| format!("not demonstrated ({})", class_word(c)),
1176 )),
1177 Some(began),
1178 ),
1179 }
1180
1181 if let Some(why) = acceptance
1185 .as_ref()
1186 .and_then(|a| a.generation_failed.clone())
1187 {
1188 let reason = format!(
1189 "the acceptance tests could not be written ({why}); nothing was attempted — run again once the model answers, or set [acceptance] generate = false to rely on the checks alone"
1190 );
1191 let _ = worktree::restore(&wt_root, &tmp, &start);
1192 let report = RunReport {
1193 reason: Some(reason.clone()),
1194 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1195 set_aside,
1196 notices: notices.clone(),
1197 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1198 };
1199 live.event(&finished_event(
1200 task,
1201 &report,
1202 "acceptance_failed",
1203 false,
1204 redact(&reason, secret),
1205 ));
1206 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1207 .with_code("ACCEPTANCE-UNWRITTEN")
1208 .with_data(CommandData::Run(Box::new(report)));
1209 }
1210
1211 if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
1212 let why = undemonstrated_why(acceptance.as_ref());
1213 let reason = format!(
1214 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1215 );
1216 let saved = attempts::save(
1218 &wt_root,
1219 &tmp,
1220 &feature_name,
1221 task,
1222 &attempts::Record {
1223 run: None,
1224 outcome: "not_demonstrated",
1225 reason: &reason,
1226 pending: None,
1227 usage: None,
1228 },
1229 )
1230 .ok()
1231 .map(|s| s.reference);
1232 let _ = worktree::restore(&wt_root, &tmp, &start);
1233 let report = RunReport {
1234 reason: Some(reason.clone()),
1235 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1236 saved_attempt: saved,
1237 set_aside,
1238 notices: notices.clone(),
1239 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1240 };
1241 live.event(&finished_event(
1242 task,
1243 &report,
1244 "not_demonstrated",
1245 false,
1246 redact(&reason, secret),
1247 ));
1248 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1249 .with_data(CommandData::Run(Box::new(report)));
1250 }
1251
1252 let before = match &interrupted {
1255 Some(m) => worktree::Checkpoint(m.before.clone()),
1256 None => match worktree::checkpoint(&wt_root, &tmp) {
1257 Ok(c) => c,
1258 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1259 },
1260 };
1261 let acceptance_saved = acceptance
1262 .as_ref()
1263 .and_then(|a| serde_json::to_value(a).ok());
1264 let frozen: Vec<String> = worktree::files(&wt_root, &before)
1266 .unwrap_or_default()
1267 .into_iter()
1268 .filter(|p| tools::paths::is_test_path(p))
1269 .collect();
1270 let mut usage: Option<serde_json::Value> = None;
1271 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1274 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1276 let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1278 let (mut report, attempt_status) = loop {
1279 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1280 let mut digest: Option<serde_json::Value> = None;
1281 let mut attempt_no = interrupted
1284 .as_ref()
1285 .filter(|m| m.sample == samples.len())
1286 .map_or(1, |m| m.attempt.max(1));
1287 let (mut report, attempt_status) = loop {
1288 let hidden: Vec<String> = options
1290 .session
1291 .as_ref()
1292 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1293 .unwrap_or_default();
1294 let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1300 Ok(_) if autopilot => (Vec::new(), Vec::new()),
1303 Ok(mut d) => {
1304 let once = d.take_once(&feature_name, task, this_run.as_str());
1305 if !once.is_empty()
1306 && let Err(e) = d.save(&s.plane_dir)
1307 {
1308 return fail(CommandStatus::Error, e);
1309 }
1310 (d.for_run(&feature_name, task, this_run.as_str()), once)
1311 }
1312 Err(e) => return fail(CommandStatus::ConfigError, e),
1313 };
1314 let run_approved: Vec<crate::tools::policy::Object> = options
1316 .run_grants
1317 .lock()
1318 .map(|g| g.clone())
1319 .unwrap_or_default();
1320 let input = substrate::attempt::input(
1322 bundle_value.clone(),
1323 &substrate::attempt::Settings {
1324 checks: &check_argvs,
1325 allow: &run_config.allow,
1326 allow_prefix: &run_config.allow_prefix,
1327 max_turns: run_config.max_turns,
1328 max_tool_calls: run_config.max_tool_calls,
1329 reasoning: s.model.reasoning_summary,
1330 frozen: &frozen,
1331 approved: &task_grants,
1332 rejected: &decisions.rejected,
1333 run_approved: &run_approved,
1334 approved_once: &approved_once,
1335 nudges: &decisions.nudges,
1336 mcp: &connected.tools,
1337 root: &wt_root_canonical,
1338 frozen_additions: run_config.frozen_additions,
1339 asks: if options.lane.is_some() {
1342 "stop"
1343 } else {
1344 "tasks"
1345 },
1346 wait_secs: run_config.approval_wait.as_secs(),
1347 network_allow: &sandbox_config.network_allow,
1348 network_allow_prefix: &sandbox_config.network_allow_prefix,
1349 run_id: this_run.as_str(),
1350 asked: options.asked_now(),
1351 budget: run_config.approval_budget,
1352 prompts: approver.is_some() && options.lane.is_none(),
1353 in_place: options.session.is_some(),
1354 hidden: &hidden,
1355 autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1356 },
1357 digest.as_ref(),
1358 attempt_instructions,
1359 (!test_layout.runners.is_empty()).then_some(test_layout_text.as_str()),
1361 );
1362 let attempt_began = std::time::Instant::now();
1363 let attempt_detail = if run_config.best_of > 1 {
1364 format!(
1365 "{attempt_no}/{} · sample {}/{}",
1366 run_config.max_attempts,
1367 samples.len() + 1,
1368 run_config.best_of
1369 )
1370 } else {
1371 format!("{attempt_no}/{}", run_config.max_attempts)
1372 };
1373 stage(
1374 &*live,
1375 task,
1376 Stage::Attempt,
1377 StageState::Started,
1378 Some(attempt_detail.clone()),
1379 None,
1380 );
1381 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1384 let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1387 let held: Vec<String> = bug_fix
1388 .as_ref()
1389 .and_then(|f| {
1390 f.bug
1391 .held_out_sealed
1392 .clone()
1393 .map(|h| (f.bug.unit.clone(), h))
1394 })
1395 .and_then(|(unit, h)| {
1396 crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1397 })
1398 .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1399 .unwrap_or_default();
1400 match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1401 Ok(x) => x,
1402 Err(stop) => return *stop,
1403 }
1404 } else if let Some(exec) = &executor {
1405 let allow: Vec<Vec<String>> = check_argvs
1406 .iter()
1407 .chain(&run_config.allow)
1408 .cloned()
1409 .collect();
1410 let prompt = external::prompt_in(format.wording(), &bundle_value, digest.as_ref());
1411 let withheld = acceptance
1412 .as_ref()
1413 .and_then(|a| a.held_out.as_ref())
1414 .map(|_| held_out_store.as_path());
1415 if withheld.is_some()
1416 && !notices
1417 .iter()
1418 .any(|n| n.starts_with("held-out set withheld"))
1419 {
1420 notices.push("held-out set withheld from the external agent, not sealed: it runs with your permissions; a copied held-out literal stops the tick".to_owned());
1421 }
1422 let o = external::run(exec, &wt_root, &prompt, &allow, &tmp, withheld);
1423 (o.run_id, true, o.output)
1424 } else {
1425 let key = match &interrupted {
1427 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
1428 m.key.clone()
1429 }
1430 _ => resume::new_key(&feature_name, task),
1431 };
1432 let _ = resume::save(
1433 &marker_path,
1434 &resume::Marker {
1435 key: key.clone(),
1436 before: before.0.clone(),
1437 head: head_now.clone(),
1438 attempt: attempt_no,
1439 sample: samples.len(),
1440 acceptance: acceptance_saved.clone(),
1441 run_id: Some(this_run.to_string()),
1442 asked: options.asked_now(),
1443 run_grants: run_approved.clone(),
1444 },
1445 );
1446 marker_guard.arm();
1447 let outcome = match rt.block_on(plane.attempt_keyed(
1448 &feature_name,
1449 task,
1450 input,
1451 &key,
1452 )) {
1453 Ok((o, resumed)) => {
1454 if resumed {
1455 notices.push(format!(
1456 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
1457 o.run_id
1458 ));
1459 }
1460 o
1461 }
1462 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1463 };
1464 let context = crate::approvals::PresentContext {
1468 root: Some(wt_root.clone()),
1469 ..present.clone()
1470 };
1471 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
1472 outcome,
1473 approver.as_deref(),
1474 &context,
1475 )) {
1476 Ok(x) => {
1477 notices.extend(crate::actions::allow_always_inline(
1480 path,
1481 &feature_name,
1482 &x.always,
1483 state,
1484 ));
1485 (x.outcome, x.waiting)
1486 }
1487 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1488 };
1489 if let Some(task_row) = waiting {
1490 marker_guard.keep();
1493 let action = &task_row.justification.proposed_action;
1494 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
1495 report.run_id = outcome.run_id.to_string();
1496 report.notices = notices;
1497 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
1498 report.tool_calls.push(serde_json::json!({
1500 "tool": action["tool"],
1501 "arguments": action["arguments"],
1502 "decision": "approval",
1503 "reason": action["request"]["reason"],
1504 "object": action["request"]["object"],
1505 "waiting": true,
1506 "call": task_row.id.to_hex(),
1507 }));
1508 report.pending = Some(serde_json::json!({
1509 "tool": action["tool"],
1510 "arguments": action["arguments"],
1511 "reason": action["request"]["reason"],
1512 "object": action["request"]["object"],
1513 "request": action["request"],
1514 "call": task_row.id.to_hex(),
1515 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
1516 "waiting": true,
1517 "batched": action["request"]["batched"],
1518 }));
1519 if let (Some(n), Ok(mut asked)) = (
1522 action["request"]["asked"]
1523 .as_u64()
1524 .and_then(|n| u32::try_from(n).ok()),
1525 options.asked.lock(),
1526 ) {
1527 *asked = (*asked).max(n);
1528 }
1529 report.reason = Some(format!(
1530 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
1531 task_row.justification.summary.peek()
1532 ));
1533 let (status, message) = status_and_message(&report, "needs_approval", false);
1534 live.event(&finished_event(
1535 task,
1536 &report,
1537 "needs_approval",
1538 false,
1539 redact(&message, secret),
1540 ));
1541 return CommandResult::new("run", status, &redact(&message, secret))
1542 .with_data(CommandData::Run(Box::new(report)));
1543 }
1544 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
1545 let mut output = outcome
1546 .output
1547 .as_ref()
1548 .map(|o| o.peek().clone())
1549 .unwrap_or(serde_json::Value::Null);
1550 if !succeeded && output["reason"].as_str().is_none() {
1551 if !output.is_object() {
1552 output = serde_json::json!({});
1553 }
1554 let reason = if rt.block_on(plane.holds_a_pre_054_decision(outcome.run_id)) {
1557 substrate::PRE_054_DECISION.to_owned()
1558 } else {
1559 format!("{:?}", outcome.status)
1560 };
1561 output["reason"] = serde_json::Value::String(redact(&reason, secret));
1562 }
1563 (outcome.run_id.to_string(), succeeded, output)
1564 };
1565 if bug_candidate.is_some()
1566 && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
1567 {
1568 c.applied = true;
1569 }
1570 if let Some(argv) = run_config.format.as_ref().filter(|_| {
1573 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
1574 }) {
1575 use verify::process::CommandRunner as _;
1576 let ran = runner.run(&verify::process::Invocation {
1577 argv: argv.clone(),
1578 cwd: project.clone(),
1579 env: verify::process::scrubbed_env(std::env::vars()),
1580 timeout: std::time::Duration::from_secs(300),
1581 output_cap: verify::process::OUTPUT_CAP,
1582 });
1583 if ran.exit_code != Some(0) {
1584 notices.push(format!(
1585 "the formatter (`{}`) did not succeed",
1586 argv.join(" ")
1587 ));
1588 }
1589 }
1590 let after = match worktree::checkpoint(&wt_root, &tmp) {
1591 Ok(c) => c,
1592 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1593 };
1594 let raw_changes = worktree::diff(&wt_root, &before, &after).unwrap_or_default();
1595 if let (Some(n), Ok(mut asked)) = (
1600 output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
1601 options.asked.lock(),
1602 ) {
1603 *asked = n;
1604 }
1605 let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
1606 let findings = attempt_findings(&Audited {
1607 wt_root: &wt_root,
1608 policy: &policy,
1609 judged_before: &judged_before,
1610 raw_changes: &raw_changes,
1611 patch: &worktree::patch(&wt_root, &before, &after).unwrap_or_default(),
1612 frozen: &frozen,
1613 frozen_additions: run_config.frozen_additions,
1614 approved_writes: &approved_writes,
1615 command_drift: &output["integrity"],
1616 });
1617 let changes = changes_of(raw_changes);
1618
1619 let quarantined = !succeeded
1624 && output["reason"]
1625 .as_str()
1626 .is_some_and(|r| r.starts_with("Quarantined("));
1627 if quarantined {
1628 output["reason"] = serde_json::Value::String(
1629 "the attempt was interrupted while a tool call was running; whether it finished cannot be known, so it was not run again. The attempt is saved and undone; run again to start a fresh attempt".to_owned(),
1630 );
1631 }
1632 let mut attempt_status = if succeeded {
1633 output["status"].as_str().unwrap_or("failed").to_owned()
1634 } else if quarantined {
1635 "interrupted".to_owned()
1636 } else {
1637 "failed".to_owned()
1638 };
1639 let mut report = RunReport {
1640 run_id: run_id.clone(),
1641 model_report: output.get("report").cloned(),
1642 reason: output["reason"].as_str().map(str::to_owned),
1643 pending: output.get("pending").cloned(),
1644 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
1645 changes,
1646 usage: output.get("usage").cloned(),
1647 integrity: findings.clone(),
1648 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1649 set_aside: set_aside.clone(),
1650 notices: notices.clone(),
1651 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
1652 };
1653 if let Err(e) = record_inline_approvals(
1654 &s.plane_dir,
1655 &feature_name,
1656 task,
1657 &report.tool_calls,
1658 &options.run_grants,
1659 this_run.as_str(),
1660 ) {
1661 report.notices.push(format!(
1662 "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
1663 ));
1664 }
1665 for c in report
1667 .tool_calls
1668 .iter()
1669 .filter(|c| c["approved_by"] == "person")
1670 {
1671 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
1672 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
1673 }
1674 }
1675
1676 stage(
1677 &*live,
1678 task,
1679 Stage::Attempt,
1680 if attempt_status == "completed" {
1681 StageState::Passed
1682 } else {
1683 StageState::Failed
1684 },
1685 Some(if attempt_status == "completed" {
1686 attempt_detail
1687 } else {
1688 format!("{attempt_detail} {attempt_status}")
1689 }),
1690 Some(attempt_began),
1691 );
1692 if attempt_status == "integrity" {
1695 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1696 reasons: findings.clone(),
1697 });
1698 }
1699 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
1701 if judged {
1702 let began = std::time::Instant::now();
1703 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
1704 if let Err(e) = judge_checks(
1705 &project,
1706 &runner,
1707 &baseline,
1708 acceptance.as_ref(),
1709 &findings,
1710 &mut report,
1711 ) {
1712 stage(
1713 &*live,
1714 task,
1715 Stage::Verify,
1716 StageState::Failed,
1717 None,
1718 Some(began),
1719 );
1720 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
1721 }
1722 let otherwise_passing = attempt_status == "completed"
1726 && findings.is_empty()
1727 && matches!(
1728 report.verdict,
1729 Some(
1730 speccoder_core::verify::Verdict::Verified
1731 | speccoder_core::verify::Verdict::RegressionOnly
1732 )
1733 );
1734 match judge_collection(
1735 (&project, &wt_root, &tmp),
1736 &runner,
1737 otherwise_passing,
1738 &mut report,
1739 &*live,
1740 task,
1741 ) {
1742 Some(Collected::Unknown(why)) => {
1743 attempt_status = "collection_unknown".to_owned();
1744 report.verdict = None;
1745 report.reason = Some(why);
1746 }
1747 Some(Collected::Changed(finding)) => {
1749 report.integrity.push(finding.clone());
1750 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1751 reasons: vec![finding],
1752 });
1753 attempt_status = "integrity".to_owned();
1754 }
1755 None => {}
1756 }
1757 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
1758 stage_with_tail(
1759 &*live,
1760 task,
1761 Stage::Verify,
1762 if checks_held {
1763 StageState::Passed
1764 } else {
1765 StageState::Failed
1766 },
1767 None,
1768 Some(began),
1769 report
1770 .verification
1771 .as_ref()
1772 .map(failing_tail)
1773 .unwrap_or_default(),
1774 );
1775 }
1776 if judged || attempt_status == "integrity" {
1777 stage(
1778 &*live,
1779 task,
1780 Stage::Audit,
1781 if findings.is_empty() {
1782 StageState::Passed
1783 } else {
1784 StageState::Failed
1785 },
1786 findings
1787 .first()
1788 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
1789 None,
1790 );
1791 }
1792
1793 let visibly_passed = judged
1797 && findings.is_empty()
1798 && attempt_status == "completed"
1799 && matches!(
1800 report.verdict,
1801 Some(
1802 speccoder_core::verify::Verdict::Verified
1803 | speccoder_core::verify::Verdict::RegressionOnly
1804 )
1805 );
1806 if let Some(sha) = acceptance
1807 .as_ref()
1808 .and_then(|a| a.held_out.clone())
1809 .filter(|_| visibly_passed)
1810 {
1811 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1812 let mut known = format!(
1813 "{}\n{}\n{}",
1814 bundle_value["story"].as_str().unwrap_or_default(),
1815 bundle_value["plan"].as_str().unwrap_or_default(),
1816 bundle_value["constitution"].as_str().unwrap_or_default()
1817 );
1818 for p in report
1819 .acceptance
1820 .as_ref()
1821 .and_then(|a| a["files"].as_array().cloned())
1822 .unwrap_or_default()
1823 {
1824 if let Some(p) = p.as_str() {
1825 known.push_str(
1826 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
1827 );
1828 }
1829 }
1830 match judge_held_out(
1831 &held_out_store,
1832 &sha,
1833 &scenarios,
1834 &known,
1835 &wt_root,
1836 &project,
1837 &tmp,
1838 &before,
1839 &patch,
1840 &runner,
1841 &policy,
1842 ) {
1843 Ok(j) => {
1844 let failed_ids: Vec<String> =
1845 j.failed.iter().map(|s| s.id.clone()).collect();
1846 held_out_log.push(serde_json::json!({
1847 "attempt": held_out_log.len() + 1,
1848 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
1849 "scenarios": failed_ids,
1850 }));
1851 if let Some(v) = report.verdict.take() {
1852 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
1853 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
1854 && !j.failed.is_empty()
1855 && let Some(last) = reasons.last_mut()
1856 {
1857 *last = speccoder_core::retry::held_out_reason(&j.failed);
1858 }
1859 report.verdict = Some(v);
1860 }
1861 }
1862 Err((outcome, why)) => {
1863 held_out_log.push(serde_json::json!({
1864 "attempt": held_out_log.len() + 1,
1865 "outcome": outcome,
1866 }));
1867 report.verdict = None;
1868 report.reason = Some(why);
1869 attempt_status = "held_out".to_owned();
1870 }
1871 }
1872 }
1873 if let Some(fix) = bug_fix.as_mut().filter(|_| {
1876 attempt_status == "completed"
1877 && findings.is_empty()
1878 && matches!(
1879 report.verdict,
1880 Some(
1881 speccoder_core::verify::Verdict::Verified
1882 | speccoder_core::verify::Verdict::RegressionOnly
1883 )
1884 )
1885 }) {
1886 let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
1887 let judging = super::bug::Judging {
1888 before: &before,
1889 baseline: &baseline,
1890 verification: report.verification.as_ref(),
1891 base: report
1892 .verdict
1893 .clone()
1894 .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
1895 keep: &keep,
1896 };
1897 match super::bug::judge(&bug_ctx, fix, &judging) {
1898 Ok(j) => {
1899 report.verdict = Some(j.verdict);
1900 if let Some(weak) = j.weak {
1901 report.reason = Some(format!(
1902 "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
1903 weak.scenario, weak.rationale
1904 ));
1905 bug_weak = Some(weak);
1906 attempt_status = "repro_weak".to_owned();
1907 }
1908 }
1909 Err(e) => {
1910 report.integrity.push(e.clone());
1911 report.reason = Some(e);
1912 report.verdict = None;
1913 attempt_status = "integrity".to_owned();
1914 }
1915 }
1916 }
1917 if let Some(h) = report
1918 .acceptance
1919 .as_mut()
1920 .and_then(|a| a.get_mut("held_out"))
1921 .filter(|h| h["state"] == "on")
1922 {
1923 let failed = held_out_log
1924 .iter()
1925 .filter(|o| o["outcome"] == "failed")
1926 .count();
1927 h["attempts"] = serde_json::json!(held_out_log);
1928 h["gap"] = serde_json::json!(failed > 0);
1929 }
1930
1931 let passing = attempt_status == "completed"
1933 && matches!(
1934 report.verdict,
1935 Some(
1936 speccoder_core::verify::Verdict::Verified
1937 | speccoder_core::verify::Verdict::RegressionOnly
1938 )
1939 );
1940 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
1941 stage(
1942 &*live,
1943 task,
1944 Stage::Mutation,
1945 StageState::Skipped,
1946 Some(if mutation_config.is_none() {
1947 "not configured".to_owned()
1948 } else {
1949 "suite = false: for acceptance strength only".to_owned()
1950 }),
1951 None,
1952 );
1953 }
1954 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
1955 let began = std::time::Instant::now();
1956 stage(
1957 &*live,
1958 task,
1959 Stage::Mutation,
1960 StageState::Started,
1961 None,
1962 None,
1963 );
1964 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1965 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
1966 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
1967 if m.mode == mutation::Mode::Require
1968 && let Some(reason) = mutation::failure(&result)
1969 {
1970 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1971 reasons: vec![reason],
1972 });
1973 }
1974 report.mutation = Some(result);
1975 }
1976 let held = !matches!(
1977 report.verdict,
1978 Some(speccoder_core::verify::Verdict::Failed { .. })
1979 );
1980 stage(
1981 &*live,
1982 task,
1983 Stage::Mutation,
1984 if held {
1985 StageState::Passed
1986 } else {
1987 StageState::Failed
1988 },
1989 report
1990 .mutation
1991 .as_ref()
1992 .and_then(crate::mutation::trailer)
1993 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
1994 Some(began),
1995 );
1996 }
1997
1998 usage = add_usage(usage.as_ref(), report.usage.as_ref());
1999 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
2001 (report.verdict.clone(), report.verification.as_ref())
2002 else {
2003 break (report, attempt_status);
2004 };
2005 if attempt_status != "completed" {
2006 break (report, attempt_status);
2007 }
2008 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
2009 let mut d =
2010 speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
2011 d.uncollected = report
2012 .collection
2013 .as_ref()
2014 .map(speccoder_core::collection::Collection::retry_lines)
2015 .unwrap_or_default();
2016 let fingerprint = speccoder_core::retry::fingerprint(&d);
2017 let stop = if bug_candidate.is_some() {
2018 Some("a candidate is judged, never retried".to_owned())
2019 } else {
2020 retry_stop(
2021 &findings,
2022 verification,
2023 earlier.last().map(|e| e.fingerprint.as_str()),
2024 &fingerprint,
2025 attempt_no,
2026 run_config.max_attempts,
2027 )
2028 };
2029 if let Some(stop) = stop {
2030 report.retry_stop = Some(stop);
2031 break (report, attempt_status);
2032 }
2033 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2034 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
2035 break (report, attempt_status);
2036 }
2037 earlier.push(speccoder_core::EarlierAttempt {
2038 run_id: report.run_id.clone(),
2039 attempt_status: attempt_status.clone(),
2040 verdict: report.verdict.clone(),
2041 fingerprint,
2042 digest: d.clone(),
2043 usage: report.usage.clone(),
2044 });
2045 digest = serde_json::to_value(&d).ok();
2046 attempt_no += 1;
2047 };
2048 report.earlier = earlier;
2049 if run_config.best_of <= 1 {
2050 break (report, attempt_status);
2051 }
2052 let end = match worktree::checkpoint(&wt_root, &tmp) {
2053 Ok(c) => c,
2054 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2055 };
2056 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
2057 let summary = sample_summary(&report, &attempt_status, &patch);
2058 samples.push((report, attempt_status, end, summary));
2059 if samples.len() >= run_config.best_of as usize {
2060 let best = best_sample(&samples);
2061 let summaries: Vec<serde_json::Value> = samples
2062 .iter()
2063 .enumerate()
2064 .map(|(i, s)| {
2065 let mut v = s.3.clone();
2066 v["kept"] = serde_json::json!(i == best);
2067 v
2068 })
2069 .collect();
2070 let (mut chosen, status, end, _) = samples.swap_remove(best);
2071 if best != samples.len() {
2072 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
2074 return fail(CommandStatus::GitPrecondition, e.0);
2075 }
2076 }
2077 chosen.samples = summaries;
2078 break (chosen, status);
2079 }
2080 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2081 return fail(CommandStatus::GitPrecondition, e.0);
2082 }
2083 };
2084 report.usage = usage;
2085 {
2088 use speccoder_core::context::ContextKind as K;
2089 let part = |k: &str| {
2090 bundle_value[k]
2091 .as_str()
2092 .unwrap_or_default()
2093 .as_bytes()
2094 .to_vec()
2095 };
2096 let mut m = crate::context::Manifest::new();
2097 m.push(
2098 K::Constitution,
2099 "worktree",
2100 "/bundle/constitution",
2101 &part("constitution"),
2102 )
2103 .push(K::Plan, "worktree", "/bundle/plan", &part("plan"))
2104 .push(K::Story, "worktree", "/bundle/story", &part("story"))
2105 .push(
2106 K::Task,
2107 "worktree",
2108 "/bundle/task",
2109 serde_json::to_string(&bundle_value["task"])
2110 .unwrap_or_default()
2111 .as_bytes(),
2112 )
2113 .push(
2114 K::TestLayout,
2115 "worktree",
2116 "/test_layout",
2117 test_layout_text.as_bytes(),
2118 );
2119 report.context = Some(m.finish());
2120 }
2121 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
2123
2124 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
2128 let mut attempt_status = attempt_status;
2129 let verified = attempt_status == "completed"
2130 && matches!(
2131 report.verdict,
2132 Some(speccoder_core::verify::Verdict::Verified)
2133 );
2134 let visible_argv = acceptance
2135 .as_ref()
2136 .filter(|a| a.red)
2137 .and_then(|a| a.argv.clone());
2138 if verified && !run_config.strength.enabled {
2139 stage(
2140 &*live,
2141 task,
2142 Stage::Strength,
2143 StageState::Skipped,
2144 Some("strength = false".to_owned()),
2145 None,
2146 );
2147 }
2148 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
2149 use speccoder_core::strength::{Gate, gate, over_strict};
2150 let began = std::time::Instant::now();
2151 stage(
2152 &*live,
2153 task,
2154 Stage::Strength,
2155 StageState::Started,
2156 None,
2157 None,
2158 );
2159 let fin = worktree::checkpoint(&wt_root, &tmp);
2160 let patch = fin
2161 .as_ref()
2162 .ok()
2163 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
2164 .unwrap_or_default();
2165 let mut notes = Vec::new();
2166 let held: Vec<(crate::held_out::HeldOutFile, String)> =
2167 match acceptance.as_ref().and_then(|a| a.held_out.clone()) {
2168 None => Vec::new(),
2169 Some(sha) => match crate::held_out::load_verified(&held_out_store, &sha) {
2170 Ok((manifest, contents)) => manifest
2171 .files
2172 .into_iter()
2173 .zip(contents.into_iter().map(|(_, c)| c))
2174 .collect(),
2175 Err(t) => {
2176 notes.push(format!("held-out set not overlaid: {}", t.0));
2177 Vec::new()
2178 }
2179 },
2180 };
2181 let rulings = crate::strength::rulings(&strength_dir);
2182 let mut commands = vec![argv];
2183 let measure = |commands: &[Vec<String>]| {
2184 crate::strength::measure(
2185 &crate::strength::Setup {
2186 wt_root: &wt_root,
2187 project: &project,
2188 tmp: &tmp,
2189 runner: &runner,
2190 policy: &policy,
2191 acceptance: commands,
2192 held_out: &held,
2193 mutation: mutation_config.as_ref(),
2194 patch: &patch,
2195 config: &run_config.strength,
2196 keep: &strength_dir,
2197 rulings: &rulings,
2198 },
2199 |changed| {
2200 let input = substrate::near_miss::input(
2202 &bundle_value,
2203 changed,
2204 &crate::mutation::source_patch(&patch),
2205 run_config.strength.near_miss,
2206 run_config.strength.permitted,
2207 );
2208 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
2209 Ok(o) => {
2210 let out = o
2211 .output
2212 .as_ref()
2213 .map(|v| v.peek().clone())
2214 .unwrap_or_default();
2215 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
2216 && !out["answer"].is_null();
2217 crate::strength::Authored {
2218 answer: if ok {
2219 Ok(out["answer"].clone())
2220 } else {
2221 Err(format!(
2222 "the variant author did not answer ({})",
2223 o.status.as_str()
2224 ))
2225 },
2226 run_id: Some(o.run_id.to_string()),
2227 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
2228 }
2229 }
2230 Err(e) => crate::strength::Authored {
2231 answer: Err(redact(&e.0, secret)),
2232 run_id: None,
2233 usage: None,
2234 },
2235 }
2236 },
2237 )
2238 };
2239 let mut measured = measure(&commands);
2240 let min = run_config.strength.min;
2243 if let (Ok(a), Ok(fin)) = (&measured, &fin)
2244 && gate(min, &a.combined, false) == Gate::Failed
2245 && over_strict(&a.permitted).is_empty()
2246 {
2247 let visible_tests: Vec<(String, String)> = report
2248 .acceptance
2249 .as_ref()
2250 .and_then(|a| a["files"].as_array().cloned())
2251 .unwrap_or_default()
2252 .iter()
2253 .filter_map(|p| p.as_str())
2254 .map(|p| {
2255 (
2256 p.to_owned(),
2257 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2258 )
2259 })
2260 .collect();
2261 let setup = AcceptanceSetup {
2262 rt: &rt,
2263 plane: &plane,
2264 project: &project,
2265 worktree: &wt_root,
2266 tmp: &tmp,
2267 policy: &policy,
2268 feature: &feature_name,
2269 task,
2270 bundle: bundle_value.clone(),
2271 scenarios: &scenarios,
2272 instructions: &instructions,
2273 runner: &runner,
2274 layout: Some(&test_layout),
2275 story_files: &story_files,
2276 };
2277 let weak = crate::strength::weak(a);
2278 match strengthen(&setup, &visible_tests, weak, &before, fin) {
2279 Ok(added) => {
2280 if let Some(acc) = report.acceptance.as_mut() {
2281 if let Some(files) = acc["files"].as_array_mut() {
2282 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
2283 }
2284 acc["strengthened"] = serde_json::json!({
2285 "files": added.files,
2286 "argv": added.argv,
2287 "run_id": added.report["run_id"],
2288 "usage": added.report["usage"],
2289 });
2290 }
2291 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
2292 commands.push(added.argv);
2293 let mut again = measure(&commands);
2294 if let Ok(b) = again.as_mut() {
2295 b.gate = gate(min, &b.combined, true);
2296 b.notes.insert(
2297 0,
2298 "strengthened once with added acceptance tests".to_owned(),
2299 );
2300 }
2301 measured = again;
2302 }
2303 Err(why) => {
2304 if let Ok(a) = measured.as_mut() {
2305 a.notes
2306 .insert(0, format!("could not strengthen the set: {why}"));
2307 }
2308 }
2309 }
2310 }
2311 match measured {
2312 Ok(mut a) => {
2313 if a.gate == Gate::Off {
2314 a.gate = gate(min, &a.combined, false);
2315 }
2316 a.notes.extend(notes);
2317 let findings = over_strict(&a.permitted);
2318 if !findings.is_empty() {
2319 attempt_status = "over_strict".to_owned();
2320 report.reason = Some(format!(
2321 "an acceptance test rejects an implementation the spec appears to allow: {}",
2322 findings
2323 .iter()
2324 .map(|p| format!("{} ({})", p.scenario, p.rationale))
2325 .collect::<Vec<_>>()
2326 .join("; ")
2327 ));
2328 } else if a.gate == Gate::Failed {
2329 attempt_status = "acceptance_weak".to_owned();
2330 report.reason = Some(format!(
2331 "acceptance set too weak: strength {} is below min_strength {}{}",
2332 speccoder_core::strength::summary(&a.combined, None),
2333 min.unwrap_or_default(),
2334 a.notes
2335 .first()
2336 .map(|n| format!(" ({n})"))
2337 .unwrap_or_default()
2338 ));
2339 } else {
2340 crate::strength::release(&strength_dir);
2341 }
2342 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
2344 report.strength = Some(a);
2345 }
2346 Err(e) => {
2347 report.integrity.push(e.clone());
2348 report.reason = Some(e);
2349 report.verdict = None;
2350 attempt_status = "integrity".to_owned();
2351 }
2352 }
2353 report.attempt_status.clone_from(&attempt_status);
2354 let held_up = attempt_status == "completed";
2355 stage(
2356 &*live,
2357 task,
2358 Stage::Strength,
2359 if held_up {
2360 StageState::Passed
2361 } else {
2362 StageState::Failed
2363 },
2364 report.strength.as_ref().map(|a| {
2365 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
2366 }),
2367 Some(began),
2368 );
2369 }
2370
2371 let verdict_passed = attempt_status == "completed"
2375 && matches!(
2376 report.verdict,
2377 Some(
2378 speccoder_core::verify::Verdict::Verified
2379 | speccoder_core::verify::Verdict::RegressionOnly
2380 )
2381 );
2382 let mut suspected = false;
2383 if !run_config.monitor && verdict_passed {
2384 stage(
2385 &*live,
2386 task,
2387 Stage::Monitor,
2388 StageState::Skipped,
2389 Some("not enabled".to_owned()),
2390 None,
2391 );
2392 }
2393 if run_config.monitor && verdict_passed {
2394 let began = std::time::Instant::now();
2395 stage(
2396 &*live,
2397 task,
2398 Stage::Monitor,
2399 StageState::Started,
2400 None,
2401 None,
2402 );
2403 let patch = worktree::checkpoint(&wt_root, &tmp)
2404 .and_then(|now| worktree::patch(&wt_root, &start, &now))
2405 .unwrap_or_default();
2406 let calls: Vec<serde_json::Value> = report
2407 .tool_calls
2408 .iter()
2409 .map(|c| {
2410 let tool = c["tool"].as_str().unwrap_or_default();
2411 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
2412 })
2413 .collect();
2414 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
2415 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
2416 report.monitor = Some(monitor);
2417 suspected = suspicion;
2418 stage(
2419 &*live,
2420 task,
2421 Stage::Monitor,
2422 if suspected {
2423 StageState::Failed
2424 } else {
2425 StageState::Passed
2426 },
2427 suspected.then(|| "suspects gaming".to_owned()),
2428 Some(began),
2429 );
2430 }
2431
2432 let in_place_stop = options
2435 .session
2436 .as_ref()
2437 .filter(|_| verdict_passed && !suspected)
2438 .and_then(|s| {
2439 s.lock()
2440 .ok()
2441 .and_then(|s| s.check_before_tick(&policy).err())
2442 });
2443 if let Some(e) = &in_place_stop {
2444 report.reason = Some(e.line());
2445 report.verdict = None;
2446 attempt_status = e.code.to_lowercase().replace('-', "_");
2448 report.attempt_status.clone_from(&attempt_status);
2449 }
2450 let passed = verdict_passed && !suspected && in_place_stop.is_none();
2451 if passed
2455 && matches!(
2456 report.verdict,
2457 Some(speccoder_core::verify::Verdict::RegressionOnly)
2458 )
2459 {
2460 use speccoder_core::regression::{Regression, RegressionFacts, RegressionReason, decide};
2461 let facts = RegressionFacts {
2462 acceptance_on: run_config.acceptance,
2463 story: acceptance_story
2465 .clone()
2466 .or_else(|| bug_fix.as_ref().map(|f| f.bug.requirement.clone()))
2467 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone())),
2468 scenarios: scenarios.len(),
2469 skip: acceptance.as_ref().and_then(|a| a.skip_cause.clone()),
2470 red_class: acceptance.as_ref().and_then(|a| {
2472 if a.red
2473 && report
2474 .acceptance
2475 .as_ref()
2476 .is_some_and(|r| r["green_flaky"] == true)
2477 {
2478 Some(speccoder_core::verify::RedClass::Flaky)
2479 } else {
2480 a.red_class
2481 }
2482 }),
2483 };
2484 let mut reason = decide(&facts);
2485 if matches!(reason, RegressionReason::AlreadyPassing { .. })
2488 && bug_fix.is_none()
2489 && let Some(a) = acceptance.as_ref()
2490 && let Some(argv) = a.argv.as_ref()
2491 {
2492 let mut files: Vec<(String, String)> = a.report["files"]
2493 .as_array()
2494 .into_iter()
2495 .flatten()
2496 .filter_map(|f| f.as_str())
2497 .filter_map(|f| {
2498 std::fs::read_to_string(project.join(f))
2499 .ok()
2500 .map(|c| (f.to_owned(), c))
2501 })
2502 .collect();
2503 let prefix = project
2504 .strip_prefix(&wt_root)
2505 .map(|p| p.to_string_lossy().replace('\\', "/"))
2506 .unwrap_or_default();
2507 let mut command = Some(argv.clone());
2510 if a.report["appended"] == true
2511 && let Some(story) = a.report["story_file"].as_str()
2512 {
2513 let at = if prefix.is_empty() {
2514 story.to_owned()
2515 } else {
2516 format!("{prefix}/{story}")
2517 };
2518 let old = crate::git::Git::at(&wt_root)
2519 .run(&["show", &format!("HEAD:{at}")])
2520 .map(|b| String::from_utf8_lossy(&b).into_owned())
2521 .unwrap_or_default();
2522 command = None;
2523 if let Some((_, content)) = files.iter_mut().find(|(f, _)| f == story)
2524 && let Some(added) = content.strip_prefix(old.as_str()).map(str::to_owned)
2525 {
2526 command = super::bisect::section_command(argv, story, &added);
2527 let imports: String = old
2528 .lines()
2529 .filter(|l| {
2530 let t = l.trim_start();
2531 ["use ", "import ", "from ", "package ", "extern crate "]
2532 .iter()
2533 .any(|k| t.starts_with(k))
2534 })
2535 .map(|l| format!("{l}\n"))
2536 .collect();
2537 *content = format!("{imports}{added}");
2538 }
2539 }
2540 let timeout = std::fs::read_to_string(project.join(crate::verify::config::FILE))
2541 .ok()
2542 .and_then(|t| crate::verify::config::parse(&t).ok())
2543 .and_then(|cs| {
2544 cs.into_iter()
2545 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
2546 .map(|c| c.timeout)
2547 .max()
2548 })
2549 .unwrap_or(std::time::Duration::from_secs(600));
2550 let found = match &command {
2551 Some(argv) => super::bisect::search(&super::bisect::Search {
2552 repo: &wt_root,
2553 prefix: &prefix,
2554 feature: &feature_name,
2555 task,
2556 files: &files,
2557 argv,
2558 scratch: &s.plane_dir.join("tmp"),
2559 sandbox: &sandbox_config,
2560 timeout,
2561 }),
2562 None => super::bisect::Found {
2564 implemented_by: None,
2565 runs: 0,
2566 },
2567 };
2568 live.event(&progress::Event::RegressionBisected {
2569 task: task.to_owned(),
2570 runs: found.runs,
2571 result: found.implemented_by.as_ref().map(|i| match i {
2572 speccoder_core::regression::ImplementedBy::Task { task, .. } => task.clone(),
2573 speccoder_core::regression::ImplementedBy::BeforeFeature { .. } => {
2574 "before_feature".to_owned()
2575 }
2576 }),
2577 });
2578 reason = RegressionReason::AlreadyPassing {
2579 implemented_by: found.implemented_by,
2580 };
2581 }
2582 report.regression = Some(Regression::of(reason));
2583 }
2584 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
2585 let began = std::time::Instant::now();
2586 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
2587 let message = tick_message(&TickFacts {
2588 feature: &feature_name,
2589 task,
2590 title: &t.title,
2591 report: &report,
2592 red: acceptance
2593 .as_ref()
2594 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
2595 checklists_overridden,
2596 extra: bug_fix
2597 .as_ref()
2598 .map(|f| {
2599 super::bug::trailers(
2600 f,
2601 &report
2602 .verdict
2603 .as_ref()
2604 .map(super::outcome::verdict_word)
2605 .unwrap_or_default(),
2606 &crate::strength::rulings(&strength_dir),
2607 )
2608 })
2609 .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
2611 .unwrap_or_default(),
2612 });
2613 match &options.session {
2614 None => {
2616 match format.tick(file, task, &t.line_hash) {
2617 Ok(()) => report.ticked = true,
2618 Err(e) => report.integrity.push(format!("tick refused: {e}")),
2619 }
2620 if report.ticked && run_config.commit_per_task {
2621 match commit_attempt(&wt_root, &message) {
2622 Ok(id) => report.commit = Some(id),
2623 Err(e) => report.integrity.push(format!("commit failed: {e}")),
2624 }
2625 }
2626 }
2627 Some(session) => {
2630 let digest =
2631 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
2632 if let Ok(mut s) = session.lock() {
2633 let done = s.commit_and_tick(
2634 &plane_dir,
2635 file,
2636 &t.line_hash,
2637 &message,
2638 &digest,
2639 || format.tick(file, task, &t.line_hash),
2640 );
2641 report.ticked = done.ticked;
2642 report.commit = done.commit;
2643 report.integrity.extend(done.problems);
2644 }
2645 }
2646 }
2647 report.instructions = attempt_instructions
2648 .iter()
2649 .map(|i| speccoder_core::InstructionFile {
2650 path: i.path.clone(),
2651 sha256: i.sha256.clone(),
2652 bytes: i.bytes,
2653 cut: i.cut,
2654 })
2655 .collect();
2656 if let Some(commit) = report
2658 .commit
2659 .clone()
2660 .filter(|_| report.ticked && run_config.attest.enabled)
2661 {
2662 let verdict = report
2663 .verdict
2664 .as_ref()
2665 .map(super::outcome::verdict_word)
2666 .unwrap_or_default();
2667 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
2668 let feature_dir = snap
2669 .feature
2670 .as_ref()
2671 .map(|f| f.feature.dir.clone())
2672 .unwrap_or_default();
2673 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
2674 let tick = crate::attest::Tick {
2675 repo: &wt_root,
2676 config: &config_text,
2677 attest: &run_config.attest,
2678 format: &format!("{:?}", format.kind()).to_lowercase(),
2679 feature: &feature_name,
2680 feature_dir: &feature_dir,
2681 task,
2682 title: &t.title,
2683 line_hash: &t.line_hash,
2684 section: t.story.as_deref(),
2685 section_text: bundle_value["story"].as_str().unwrap_or_default(),
2686 requirements: &requirements,
2687 commit: &commit,
2688 verdict: &verdict,
2689 sandbox: serde_json::json!({
2690 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
2691 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
2692 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
2693 }),
2694 executor: serde_json::json!({
2695 "kind": if bug_candidate.is_some() { "candidate".to_owned() } else { executor.as_ref().map_or_else(|| "native".to_owned(), |e| match &e.kind {
2696 external::Kind::Claude => "claude".to_owned(),
2697 external::Kind::Codex => "codex".to_owned(),
2698 external::Kind::Command(_) => "command".to_owned(),
2699 }) },
2700 "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
2701 }),
2702 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
2703 bug: bug_fix
2704 .as_ref()
2705 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
2706 };
2707 report.attestation = Some(crate::attest::record_tick(&tick, &report));
2708 }
2709 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
2710 stage(
2711 &*live,
2712 task,
2713 Stage::Commit,
2714 if committed {
2715 StageState::Passed
2716 } else {
2717 StageState::Failed
2718 },
2719 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
2720 Some(began),
2721 );
2722 }
2723 if passed && let Some(n) = &native {
2726 let began = std::time::Instant::now();
2727 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
2728 let verdict = report
2729 .verdict
2730 .as_ref()
2731 .map(super::outcome::verdict_word)
2732 .unwrap_or_default();
2733 let message = tick_message(&TickFacts {
2734 feature: &feature_name,
2735 task,
2736 title: &n.title,
2737 report: &report,
2738 red: acceptance
2739 .as_ref()
2740 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
2741 checklists_overridden: false,
2742 extra: bug_fix
2743 .as_ref()
2744 .map(|f| {
2745 super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
2746 })
2747 .unwrap_or_default(),
2748 });
2749 match commit_attempt(&wt_root, &message) {
2750 Ok(id) => report.commit = Some(id),
2751 Err(e) => report.integrity.push(format!("commit failed: {e}")),
2752 }
2753 if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
2754 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
2755 let tick = crate::attest::Tick {
2756 repo: &wt_root,
2757 config: &config_text,
2758 attest: &run_config.attest,
2759 format: &format!("{:?}", format.kind()).to_lowercase(),
2760 feature: &feature_name,
2761 feature_dir: &n.dir,
2762 task,
2763 title: &n.title,
2764 line_hash: "",
2765 section: None,
2766 section_text: &n.story,
2767 requirements: &[],
2768 commit: &commit,
2769 verdict: &verdict,
2770 sandbox: serde_json::json!({
2771 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
2772 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
2773 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
2774 }),
2775 executor: serde_json::json!({
2776 "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
2777 }),
2778 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
2779 bug: bug_fix
2780 .as_ref()
2781 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
2782 };
2783 report.attestation = Some(crate::attest::record_tick(&tick, &report));
2784 }
2785 stage(
2786 &*live,
2787 task,
2788 Stage::Commit,
2789 if report.commit.is_some() {
2790 StageState::Passed
2791 } else {
2792 StageState::Failed
2793 },
2794 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
2795 Some(began),
2796 );
2797 }
2798
2799 if attempt_status == "repro_weak"
2804 && let Some(weak) = &bug_weak
2805 && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
2806 {
2807 report
2808 .integrity
2809 .push(format!("holding the attempt failed: {e}"));
2810 }
2811 if attempt_status == "over_strict" || attempt_status == "repro_weak" {
2812 let findings = report
2813 .strength
2814 .as_ref()
2815 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
2816 .unwrap_or_default();
2817 if attempt_status == "over_strict"
2818 && let Err(e) = crate::strength::hold(&strength_dir, &findings)
2819 {
2820 report
2821 .integrity
2822 .push(format!("holding the attempt failed: {e}"));
2823 }
2824 marker_guard.keep();
2825 }
2826 if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
2829 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
2830 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
2831 let reason = redact(&reason, secret);
2832 let record = attempts::Record {
2833 run,
2834 outcome: &outcome,
2835 reason: &reason,
2836 pending: report.pending.as_ref(),
2837 usage: report.usage.as_ref(),
2838 };
2839 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
2840 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
2841 {
2842 Ok(saved) => report.saved_attempt = Some(saved.reference),
2843 Err(e) => report
2844 .integrity
2845 .push(format!("saving the attempt failed: {}", e.0)),
2846 }
2847 }
2848
2849 let (status, message) = status_and_message(&report, &attempt_status, suspected);
2850 if let Some(fix) = bug_fix.as_mut() {
2851 return super::bug::finished(
2852 &bug_ctx,
2853 fix,
2854 report,
2855 &attempt_status,
2856 passed,
2857 status,
2858 redact(&message, secret),
2859 );
2860 }
2861 live.event(&finished_event(
2862 task,
2863 &report,
2864 &attempt_status,
2865 suspected,
2866 redact(&message, secret),
2867 ));
2868 let code = collection_code(&report, &attempt_status);
2869 let result = CommandResult::new("run", status, &redact(&message, secret));
2870 match code {
2871 Some(c) => result.with_code(c),
2872 None => result,
2873 }
2874 .with_data(CommandData::Run(Box::new(report)))
2875}
2876
2877fn collection_code(
2880 report: &speccoder_core::RunReport,
2881 attempt_status: &str,
2882) -> Option<&'static str> {
2883 if attempt_status == "collection_unknown" {
2884 return Some("TESTS-COLLECTION-UNKNOWN");
2885 }
2886 match &report.verdict {
2887 Some(speccoder_core::verify::Verdict::Failed { reasons })
2888 if reasons
2889 .first()
2890 .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
2891 {
2892 Some("TESTS-NOT-COLLECTED")
2893 }
2894 _ => None,
2895 }
2896}
2897
2898enum Collected {
2904 Unknown(String),
2906 Changed(String),
2908}
2909
2910fn judge_collection(
2911 dirs: (&Path, &Path, &Path),
2912 runner: &sandbox::Runner,
2913 otherwise_passing: bool,
2914 report: &mut speccoder_core::RunReport,
2915 live: &dyn progress::Progress,
2916 task: &str,
2917) -> Option<Collected> {
2918 let (project, wt_root, tmp) = dirs;
2919 let before = worktree::checkpoint(wt_root, tmp).ok();
2921 let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
2922 .ok()
2923 .and_then(|t| crate::verify::config::parse(&t).ok())
2924 .unwrap_or_default()
2925 .into_iter()
2926 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
2927 .collect::<Vec<_>>();
2928 let results = report
2929 .verification
2930 .as_ref()
2931 .map(|v| v.checks.clone())
2932 .unwrap_or_default();
2933 let c = crate::collection::judge(&crate::collection::Input {
2934 root: project,
2935 changes: &report.changes,
2936 checks: &checks,
2937 results: &results,
2938 runner,
2939 listings: otherwise_passing,
2940 })?;
2941 if !c.listing.is_empty()
2942 && let Some(before) = &before
2943 {
2944 let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
2945 .and_then(|after| worktree::diff(wt_root, before, &after))
2946 .map(|d| {
2947 d.into_iter()
2948 .map(|c| c.path)
2949 .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
2950 .collect()
2951 })
2952 .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
2953 if !changed.is_empty() {
2954 report.collection = Some(c);
2955 return Some(Collected::Changed(format!(
2956 "integrity: a test listing changed the worktree: {}",
2957 changed.join(", ")
2958 )));
2959 }
2960 }
2961 live.event(&progress::Event::CollectionChecked {
2962 task: task.to_owned(),
2963 files: c
2964 .files
2965 .iter()
2966 .map(|f| progress::CollectionFile {
2967 path: f.path.clone(),
2968 state: f.state,
2969 decided_by: f.decided_by,
2970 })
2971 .collect(),
2972 });
2973 let never = c.reasons();
2974 let unknown: Vec<String> = c
2975 .unknown()
2976 .map(|f| {
2977 format!(
2978 "speccoder cannot tell whether the runner ran `{}` ({})",
2979 f.path,
2980 f.why.as_deref().unwrap_or("no report or listing names it")
2981 )
2982 })
2983 .collect();
2984 report.collection = Some(c);
2985 if !never.is_empty() {
2986 let mut reasons = never;
2987 if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
2988 report.verdict.take()
2989 {
2990 reasons.extend(more);
2991 }
2992 report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
2993 return None;
2994 }
2995 (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
2996}
2997
2998fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
3001 let said = c
3002 .stderr_tail
3003 .lines()
3004 .chain(c.stdout_tail.lines())
3005 .map(str::trim)
3006 .find(|l| l.starts_with("error"))
3007 .map(|l| l.chars().take(200).collect::<String>());
3008 let fix = match said.as_deref() {
3009 Some(l) if l.contains("profile `speccoder` not found") => Some(
3010 "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
3011 ),
3012 Some(l) if l.contains("no tests to run") => Some(
3013 "the project has no tests yet; if the first task writes them, run with --allow-failing-baseline, or add `--no-tests=pass` to the check's argv",
3014 ),
3015 Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
3016 "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
3017 ),
3018 _ => None,
3019 };
3020 match (said, fix) {
3021 (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
3022 (Some(s), None) => format!("{note} ({s})"),
3023 _ => note.to_owned(),
3024 }
3025}
3026
3027#[must_use]
3031pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
3032 use speccoder_core::verify::CheckStatus;
3033 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
3034 return None;
3035 }
3036 if let Some(note) = &c.note {
3037 return Some(with_cause(note, c));
3038 }
3039 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
3040 return None;
3041 }
3042 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
3043 let runner = c
3047 .argv
3048 .first()
3049 .map(|a| a.rsplit('/').next().unwrap_or(a))
3050 .is_some_and(|a| {
3051 matches!(
3052 a,
3053 "cargo"
3054 | "pytest"
3055 | "python"
3056 | "python3"
3057 | "uv"
3058 | "npm"
3059 | "npx"
3060 | "pnpm"
3061 | "yarn"
3062 | "bun"
3063 | "go"
3064 | "jest"
3065 | "vitest"
3066 | "mvn"
3067 | "gradle"
3068 | "dotnet"
3069 | "mix"
3070 | "swift"
3071 )
3072 });
3073 let cannot_start = matches!(c.exit_code, Some(126 | 127));
3074 let is_broken = match &summary {
3075 _ if cannot_start => true,
3076 Some(s) if s.build_failed => true,
3077 Some(s) => s.passed + s.failed + s.errors == 0,
3078 None => runner,
3079 };
3080 match is_broken {
3081 false => None,
3082 true => {
3083 let first = c
3084 .stderr_tail
3085 .lines()
3086 .chain(c.stdout_tail.lines())
3087 .map(str::trim)
3088 .find(|l| l.to_lowercase().contains("error"))
3089 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
3090 .unwrap_or("it failed without running a test")
3091 .chars()
3092 .take(160)
3093 .collect::<String>();
3094 Some(first)
3095 }
3096 }
3097}