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
111pub(crate) fn run_task(
115 path: &Path,
116 feature: Option<&str>,
117 task: &str,
118 state: &Path,
119 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
120 secret: Option<&str>,
121 options: &RunOptions,
122) -> CommandResult {
123 let first = run_task_once(
124 path,
125 feature,
126 task,
127 state,
128 Arc::clone(&provider),
129 secret,
130 options,
131 );
132 match first.data() {
133 Some(CommandData::Run(r)) if r.attempt_status == CHANGED_CODE => {
134 if let Some(p) = &options.progress {
135 p.event(&progress::Event::Notice {
136 text: format!("{task} changed code, so it runs as code"),
137 });
138 }
139 run_task_once(path, feature, task, state, provider, secret, options)
140 }
141 _ => first,
142 }
143}
144
145pub const CHANGED_CODE: &str = "planning_changed_code";
148
149#[allow(clippy::too_many_lines)] fn run_task_once(
151 path: &Path,
152 feature: Option<&str>,
153 task: &str,
154 state: &Path,
155 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
156 secret: Option<&str>,
157 options: &RunOptions,
158) -> CommandResult {
159 use speccoder_core::RunReport;
160 if options.in_place && options.session.is_none() {
162 return in_session("run", path, feature, state, options, |o| {
163 run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
164 });
165 }
166 let approver = options.approver.clone();
167 use speccoder_core::snapshot::TaskState;
168
169 let refuse =
170 |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
171
172 let s = match setup(path, feature, state, "run") {
174 Ok(s) => s,
175 Err(r) => return *r,
176 };
177 let native = options.bug_home.clone();
179 let feature_name = match (&native, s.snapshot.feature.as_ref()) {
180 (Some(n), _) => n.unit.clone(),
181 (None, Some(f)) => f.feature.name.clone(),
182 (None, None) => {
183 return refuse(
184 CommandStatus::SelectionNeeded,
185 "no feature selected; pass --feature".to_owned(),
186 );
187 }
188 };
189 let plane_dir = match &options.lane {
192 Some(lane) => s.plane_dir.join("lanes").join(lane),
193 None => s.plane_dir.clone(),
194 };
195 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
197 &plane_dir,
198 s.model.prices,
199 options.progress.clone(),
200 ));
201
202 let wt = match &native {
204 Some(n) => crate::commands::worktree_of(&s.snapshot, &n.unit, &n.dir, state),
205 None => workspace(path, Some(&feature_name), state, options),
206 };
207 let Some(CommandData::Worktree(w)) = wt.data() else {
208 return CommandResult::carried("run", &wt);
209 };
210 if wt.status() != CommandStatus::Ok {
211 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
212 }
213 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
214 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
215 };
216 let (wt_path, project_dir) = match &options.lane {
217 None => (wt_path, project_dir),
218 Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
219 {
220 Ok(paths) => paths,
221 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
222 },
223 };
224 let project = std::path::PathBuf::from(&project_dir);
225 let wt_root = std::path::PathBuf::from(&wt_path);
226 let tmp = plane_dir.join("tmp");
227
228 let elsewhere = if options.lane.is_none() {
234 match crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task) {
235 Ok(e) => e,
236 Err(e) => {
237 return refuse(
238 CommandStatus::Error,
239 format!(
240 "the calls waiting for a person could not be read, so whether another task's attempt holds the worktree is unknown: {e}"
241 ),
242 );
243 }
244 }
245 } else {
246 crate::approvals::Elsewhere::default()
247 };
248 if let Some((other, covers)) = elsewhere.waiting {
249 return refuse(
250 CommandStatus::NeedsHuman,
251 format!(
252 "{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}`"
253 ),
254 );
255 }
256 if options.lane.is_none()
258 && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
259 {
260 return refuse(
261 CommandStatus::NeedsHuman,
262 format!(
263 "{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`"
264 ),
265 )
266 .with_code("TESTS-OVER-STRICT");
267 }
268 let commits_per_task = verify::find_config(&project)
272 .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
273 .and_then(|t| substrate::run_config::parse(&t).ok())
274 .is_none_or(|c| c.commit_per_task);
275 let marker_path = resume::path(&plane_dir, &feature_name, task);
278 if options.fresh_acceptance && options.lane.is_none() {
281 let _ = std::fs::remove_file(&marker_path);
282 crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
283 }
284 let mut head_now = git::Git::at(&wt_root)
285 .line(&["rev-parse", "HEAD"])
286 .unwrap_or_default();
287 let autopilot = options.mode == speccoder_core::oracle::RunMode::Autopilot;
292 let interrupted = resume::load(&marker_path).filter(|m| {
293 m.head == head_now && options.lane.is_none() && !head_now.is_empty() && !autopilot
294 });
295 if let Some(m) = &interrupted {
298 if let (Some(id), Ok(mut run)) = (&m.run_id, options.run_id.lock()) {
299 *run = crate::pipeline::RunId(id.clone());
300 }
301 if let Ok(mut asked) = options.asked.lock() {
302 *asked = (*asked).max(m.asked);
303 }
304 if let Ok(mut g) = options.run_grants.lock() {
305 for o in &m.run_grants {
306 if !g.contains(o) {
307 g.push(o.clone());
308 }
309 }
310 }
311 }
312 let marker_guard = resume::Guard::new(marker_path.clone());
313 let resumed_from = interrupted.as_ref().map(|_| {
316 std::fs::read_to_string(plane_dir.join(progress::FILE))
317 .ok()
318 .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
319 .filter(|l| l.task == task)
320 .map_or((None, None), |l| (l.stage, l.last_call))
321 });
322 let mut set_aside = None;
323 if interrupted.is_none()
325 && commits_per_task
326 && options.session.is_none()
327 && worktree::require_clean(&wt_root).is_err()
328 {
329 let left = worktree::leftovers(&wt_root);
333 if !left.is_empty() {
334 let mut what = Vec::new();
335 if !left.commit.is_empty() {
336 what.push(format!("{} is not committed", left.commit.join(", ")));
337 }
338 if !left.ignore.is_empty() {
339 what.push(format!("{} is not git-ignored", left.ignore.join(", ")));
340 }
341 let checkout = std::path::Path::new(&s.snapshot.root);
342 let remedy = crate::code::setup_remedy(checkout, Some(&feature_name));
343 let command = remedy
344 .as_ref()
345 .map_or_else(|| left.fix(checkout, &wt_root, &[]), |r| r.command.clone());
346 return refuse(
347 CommandStatus::GitPrecondition,
348 format!(
349 "{} — the checks write it on every run, so each run would find it in the worktree and set it aside",
350 what.join(" and "),
351 ),
352 )
353 .with_code("SETUP-INCOMPLETE")
354 .with_fix(vec!["speccoder fix".to_owned(), command])
355 .with_remedy(remedy);
356 }
357 match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
358 Ok(reference) => set_aside = Some(reference),
359 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
360 }
361 }
362
363 let format = crate::format::at(&project);
365 let Some(snap) = format.inspect(
366 &project,
367 if native.is_some() {
368 None
369 } else {
370 Some(&feature_name)
371 },
372 ) else {
373 return refuse(
374 CommandStatus::NotAProject,
375 format.messages().no_project_in_worktree.to_owned(),
376 );
377 };
378 if native.is_some()
379 && crate::bug::state::load(&s.plane_dir, &feature_name, task)
380 .is_some_and(|b| b.outcome == speccoder_core::bug::Outcome::Fixed)
381 {
382 return refuse(
383 CommandStatus::TaskNotReady,
384 format!("{task} is already fixed"),
385 )
386 .with_code("TASK-DONE");
387 }
388 if native.is_none() {
391 let Some(st) = snap
392 .feature
393 .as_ref()
394 .and_then(|f| f.schedule.as_ref())
395 .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
396 else {
397 return refuse(
398 CommandStatus::NotFound,
399 format!("no task {task} in {feature_name}"),
400 );
401 };
402 match st.state {
403 TaskState::Done => {
404 return refuse(
405 CommandStatus::TaskNotReady,
406 format!("{task} is already done"),
407 )
408 .with_code("TASK-DONE");
409 }
410 TaskState::Blocked => {
411 return refuse(
412 CommandStatus::TaskNotReady,
413 format!("{task} is blocked by {}", st.blocked_by.join(", ")),
414 )
415 .with_code("TASK-BLOCKED");
416 }
417 TaskState::Ready => {}
418 }
419 }
420 let checklists_overridden = if native.is_some() {
423 false
424 } else {
425 match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
426 Ok(overridden) => overridden,
427 Err(stop) => return *stop,
428 }
429 };
430 let parsed_task = snap
431 .feature
432 .as_ref()
433 .and_then(|f| f.tasks.as_ref())
434 .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
435 let tasks_file = snap
436 .feature
437 .as_ref()
438 .and_then(|f| f.tasks.as_ref())
439 .map(|t| project.join(&t.path));
440
441 let bundle = match native
442 .as_ref()
443 .map(|n| n.bundle(&project))
444 .map_or_else(|| format.bundle(&snap, task), Ok)
445 {
446 Ok(b) => b,
447 Err(_) => {
448 return refuse(
449 CommandStatus::NotFound,
450 format!("no task {task} in the worktree's {feature_name}"),
451 );
452 }
453 };
454 let acceptance_story = native
457 .is_none()
458 .then(|| crate::acceptance_at::story_at(&s.plane_dir, &feature_name, task))
459 .flatten();
460 let story_task: Option<String> = acceptance_story.as_ref().and_then(|st| {
461 snap.feature
462 .as_ref()
463 .and_then(|f| f.tasks.as_ref())
464 .and_then(|t| {
465 t.tasks
466 .iter()
467 .find(|t| t.story.as_deref() == Some(st.as_str()))
468 })
469 .map(|t| t.id.clone())
470 });
471 let bundle = match (&acceptance_story, &story_task) {
472 (Some(st), Some(other)) => {
473 let mut b = bundle;
474 if let Ok(of) = format.bundle(&snap, other) {
475 b.story = of.story;
476 }
477 b.task.story.clone_from(st);
478 b
479 }
480 _ => bundle,
481 };
482 let Some(config_root) = verify::find_config(&project) else {
483 return refuse(
484 CommandStatus::ConfigError,
485 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
486 )
487 .with_code("CONFIG-MISSING");
488 };
489 let config_text =
490 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
491 let run_config = match substrate::run_config::parse(&config_text) {
492 Ok(c) => c,
493 Err(e) => {
494 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
495 .with_code("CONFIG-INVALID");
496 }
497 };
498 if run_config.review_required
501 && let Some(f) = snap.feature.as_ref()
502 && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
503 && !t.complete()
504 {
505 return refuse(
506 CommandStatus::NeedsHuman,
507 format!(
508 "{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",
509 t.approved, t.open, t.changes_requested, t.lapsed, t.questions
510 ),
511 )
512 .with_code("REVIEW-PENDING");
513 }
514 if run_config.review_required {
517 let ambiguous = match crate::oracle::open_ambiguities(
518 &s.plane_dir,
519 &feature_name,
520 &format.oracle_requirements(&snap),
521 ) {
522 Ok(a) => a,
523 Err(t) => {
524 return refuse(
525 CommandStatus::NeedsHuman,
526 format!(
527 "{}: whether it is read two ways is unknown and [review] required is on",
528 t.0
529 ),
530 )
531 .with_code("ORACLE-TAMPERED");
532 }
533 };
534 if let Some((req, scenario)) = ambiguous.first() {
535 return refuse(
536 CommandStatus::NeedsHuman,
537 format!(
538 "{req} is read two ways ({scenario}) and [review] required is on: choose a reading or reword it first"
539 ),
540 )
541 .with_code("REVIEW-AMBIGUOUS")
542 .with_fix(vec![
543 format!("speccoder review tests {req} --reading A {scenario}"),
544 format!("speccoder review tests {req} --reading B {scenario}"),
545 ]);
546 }
547 }
548 let executor = match external::parse(&config_text) {
549 Ok(e) => e,
550 Err(e) => {
551 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
552 .with_code("CONFIG-INVALID");
553 }
554 };
555 let executor = match options.agent.as_deref() {
557 None => executor,
558 Some("native") => None,
559 Some(id) => match external::for_agent(id, executor.as_ref()) {
560 Ok(e) => Some(e),
561 Err(e) => return refuse(CommandStatus::UsageError, e),
562 },
563 };
564 let sandbox_config = match sandbox::parse(&config_text) {
566 Ok(c) => c,
567 Err(e) => {
568 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
569 .with_code("CONFIG-INVALID");
570 }
571 };
572 let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
573 Ok(r) => r,
574 Err(e) => return refuse(CommandStatus::ConfigError, e),
575 };
576 if options.mode == speccoder_core::oracle::RunMode::Autopilot && !runner.is_sandboxed() {
578 return refuse(
579 CommandStatus::ConfigError,
580 "autopilot needs the OS sandbox, and commands here would run unsandboxed".to_owned(),
581 )
582 .with_code("SANDBOX-UNAVAILABLE");
583 }
584 let mutation_config = match mutation::parse(&config_text) {
585 Ok(c) => c,
586 Err(e) => {
587 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
588 .with_code("CONFIG-INVALID");
589 }
590 };
591 let worktree_config = match crate::prepare::parse(&config_text) {
592 Ok(c) => c,
593 Err(e) => {
594 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
595 .with_code("CONFIG-INVALID");
596 }
597 };
598 let check_argvs = verify::check_argvs(&config_root);
599 let unit_dir = snap
603 .feature
604 .as_ref()
605 .map(|f| f.feature.dir.clone())
606 .unwrap_or_default();
607 let changed_code = crate::attempts::list(&wt_root, &feature_name)
608 .iter()
609 .any(|a| a.task == task && a.outcome == CHANGED_CODE);
610 let planning: Option<String> = parsed_task
611 .as_ref()
612 .filter(|t| {
613 native.is_none()
614 && !changed_code
615 && speccoder_core::lane::by_paths(&t.title, &unit_dir, &format.rules().judged)
616 == speccoder_core::lane::Lane::Planning
617 })
618 .map(|_| {
619 let prefix = project
620 .strip_prefix(&wt_root)
621 .map(|p| p.to_string_lossy().replace('\\', "/"))
622 .unwrap_or_default();
623 if prefix.is_empty() {
624 unit_dir.trim_end_matches('/').to_owned()
625 } else {
626 format!("{prefix}/{}", unit_dir.trim_end_matches('/'))
627 }
628 });
629 let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow)
630 .with_prefixes(run_config.allow_prefix.clone())
631 .rules(format.rules());
632 let policy = match &planning {
633 Some(dir) => policy.planning(dir),
634 None => policy,
635 };
636 let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
639 {
640 Some(s) => s.confine(policy, runner),
641 None => (policy, runner, None),
642 };
643 let sandbox_notice = sandbox_notice.or(carve_notice);
644 if let Some(e) = options.session.as_ref().and_then(|s| {
647 s.lock().ok().and_then(|s| {
648 if interrupted.is_some() {
650 s.check_before_tick(&policy).err()
651 } else {
652 s.check(&policy).err()
653 }
654 })
655 }) {
656 return e.result("run");
657 }
658
659 let wt_root_canonical = std::fs::canonicalize(&wt_root)
660 .unwrap_or_else(|_| wt_root.clone())
661 .display()
662 .to_string();
663
664 let this_run = options.run();
667 let (decisions, task_grants) = match approvals::Decisions::load(&s.plane_dir) {
668 Ok(mut d) => {
669 if !autopilot
672 && d.claim(&feature_name, task, this_run.as_str())
673 && let Err(e) = d.save(&s.plane_dir)
674 {
675 return refuse(CommandStatus::Error, e);
676 }
677 (
678 d.for_task(&feature_name, task),
679 if autopilot {
680 Vec::new()
681 } else {
682 d.for_run(&feature_name, task, this_run.as_str())
683 },
684 )
685 }
686 Err(e) => return refuse(CommandStatus::ConfigError, e),
687 };
688
689 if let Some((stage, last_call)) = &resumed_from {
690 live.event(&progress::Event::Resumed {
691 task: task.to_owned(),
692 stage: *stage,
693 last_call: last_call.clone(),
694 });
695 }
696 live.event(&progress::Event::TaskStarted {
697 feature: feature_name.clone(),
698 task: task.to_owned(),
699 title: parsed_task
700 .as_ref()
701 .map(|t| t.title.clone())
702 .unwrap_or_default(),
703 });
704 if planning.is_some()
705 && let Some(t) = &parsed_task
706 {
707 live.event(&progress::Event::Notice {
708 text: format!(
709 "📐 planning · writes {}",
710 speccoder_core::lane::task_paths(&t.title, &unit_dir).join(", ")
711 ),
712 });
713 }
714 let fail = |status: CommandStatus, m: String| {
716 live.event(&progress::Event::TaskFinished {
717 task: task.to_owned(),
718 outcome: "error".to_owned(),
719 message: redact(&m, secret),
720 run: None,
721 input_tokens: 0,
722 output_tokens: 0,
723 cache_read_tokens: 0,
724 cache_write_tokens: 0,
725 usd: None,
726 regression: None,
727 });
728 refuse(status, m)
729 };
730 let mut prepare_notes: Vec<String> = Vec::new();
734 let mut copied_files: Vec<String> = Vec::new();
737 if options.session.is_none() {
738 match crate::include::copy(
739 &wt_root,
740 &project,
741 Path::new(&s.snapshot.root),
742 &worktree_config.include,
743 ) {
744 Ok(c) => {
745 copied_files.clone_from(&c.files);
746 if !c.now.is_empty() {
747 prepare_notes.push(format!(
748 "copied from your checkout ([worktree] include): {}",
749 c.now.join(", ")
750 ));
751 }
752 prepare_notes.extend(
753 c.skipped
754 .iter()
755 .map(|n| format!("not copied ([worktree] include): {n}")),
756 );
757 for n in &prepare_notes {
758 live.event(&progress::Event::Notice { text: n.clone() });
759 }
760 }
761 Err(e) => {
762 return fail(
763 CommandStatus::ConfigError,
764 format!("the files [worktree] include names could not be copied: {e}"),
765 )
766 .with_code("PREPARE-FAILED");
767 }
768 }
769 }
770 let prepares =
771 options.session.is_none() && worktree_config.prepare && crate::prepare::needed(&project);
772 if prepares {
773 let began = std::time::Instant::now();
774 stage(
775 &*live,
776 task,
777 Stage::Prepare,
778 StageState::Started,
779 None,
780 None,
781 );
782 let prepared = crate::prepare::prepare(&crate::prepare::Prepare {
783 worktree: &wt_root,
784 project: &project,
785 config: &worktree_config,
786 runner: &runner,
787 state: Some(state),
788 });
789 match prepared {
790 Ok(None) => {}
791 Ok(Some(p)) => {
792 stage(
793 &*live,
794 task,
795 Stage::Prepare,
796 if p.done.is_empty() {
797 StageState::Skipped
798 } else {
799 StageState::Passed
800 },
801 Some(if p.done.is_empty() {
802 "reused: the lockfiles are unchanged".to_owned()
803 } else {
804 p.done.join(", ")
805 }),
806 Some(began),
807 );
808 }
809 Err(f) => {
810 stage(
811 &*live,
812 task,
813 Stage::Prepare,
814 StageState::Failed,
815 Some(f.message.clone()),
816 Some(began),
817 );
818 return fail(CommandStatus::ConfigError, f.message)
819 .with_code("PREPARE-FAILED")
820 .with_fix(f.fix)
821 .with_commands(f.commands);
822 }
823 }
824 } else if options.session.is_none() {
825 crate::prepare::forget(&wt_root);
827 }
828
829 let began = std::time::Instant::now();
831 stage(
832 &*live,
833 task,
834 Stage::Baseline,
835 StageState::Started,
836 None,
837 None,
838 );
839 let mut baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner)
840 {
841 Ok(b) => b,
842 Err(e) => {
843 stage(
844 &*live,
845 task,
846 Stage::Baseline,
847 StageState::Failed,
848 None,
849 Some(began),
850 );
851 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
852 }
853 };
854 if !options.allow_failing_baseline
860 && !super::story::open_oracle(&wt_root)
861 && let Some((c, why)) = baseline
862 .checks
863 .iter()
864 .find_map(|c| broken(c).map(|w| (c, w)))
865 {
866 stage(
867 &*live,
868 task,
869 Stage::Baseline,
870 StageState::Failed,
871 Some(format!("`{}` cannot run", c.name)),
872 Some(began),
873 );
874 let checkout = std::path::Path::new(&s.snapshot.root);
877 if let Some(remedy) = crate::code::setup_remedy(checkout, Some(&feature_name)) {
878 return fail(
879 CommandStatus::GitPrecondition,
880 format!(
881 "check `{}` cannot run in the worktree: your checkout has not committed its setup — {why}",
882 c.name
883 ),
884 )
885 .with_code("SETUP-INCOMPLETE")
886 .with_fix(vec!["speccoder fix".to_owned(), remedy.command.clone()])
887 .with_remedy(Some(remedy));
888 }
889 return fail(
890 CommandStatus::ConfigError,
891 if why.contains("git add") || why.contains("--allow-failing-baseline") {
894 format!(
895 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why}",
896 c.name
897 )
898 } else {
899 format!(
900 "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",
901 c.name
902 )
903 },
904 )
905 .with_code("CHECK-BROKEN");
906 }
907 let failing = baseline
909 .checks
910 .iter()
911 .filter(|c| c.status.is_failure())
912 .count();
913 stage(
914 &*live,
915 task,
916 Stage::Baseline,
917 StageState::Passed,
918 (failing > 0).then(|| format!("{failing} check(s) already failing")),
919 Some(began),
920 );
921
922 let rt = match tokio_runtime() {
923 Ok(rt) => rt,
924 Err(e) => return fail(CommandStatus::RunFailed, e),
925 };
926 let bundle_value = match serde_json::to_value(&bundle) {
927 Ok(v) => v,
928 Err(e) => return fail(CommandStatus::Error, e.to_string()),
929 };
930 let instructions = crate::instructions::collect(
934 &wt_root,
935 &project,
936 &speccoder_core::scope::named_paths(&bundle.task.title),
937 &run_config.instructions,
938 );
939 let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
940 &instructions
941 } else {
942 &[]
943 };
944
945 let servers = match mcp::parse(&config_text) {
947 Ok(s) => s,
948 Err(e) => {
949 return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
950 .with_code("CONFIG-INVALID");
951 }
952 };
953 let mut connected = if servers.is_empty() {
954 mcp::Connected::default()
955 } else {
956 live.event(&progress::Event::Notice {
957 text: format!("starting {} MCP server(s)", servers.len()),
958 });
959 rt.block_on(mcp::connect(&servers, &project, &runner))
960 };
961 let approved_tools: Vec<String> = task_grants
963 .iter()
964 .filter_map(|g| g.tool_name().map(str::to_owned))
965 .collect();
966 let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
967 connected.notices.extend(pinned);
968
969 let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
972 Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
973 Err(e) => return fail(CommandStatus::Error, e.to_string()),
974 };
975 let swept =
978 match crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc()) {
979 Ok(p) => p,
980 Err(e) => {
981 return fail(
982 CommandStatus::RunFailed,
983 format!("the calls past their deadline could not be denied: {e}"),
984 );
985 }
986 };
987 let present = crate::approvals::PresentContext {
989 root: None,
990 sandbox: runner.is_sandboxed().then(|| {
991 if cfg!(target_os = "macos") {
992 "Seatbelt".to_owned()
993 } else {
994 "Landlock+seccomp".to_owned()
995 }
996 }),
997 network: match sandbox_config.network {
998 sandbox::Network::Off => "none",
999 sandbox::Network::Loopback => "loopback",
1000 sandbox::Network::On => "unrestricted",
1001 }
1002 .to_owned(),
1003 file_text: None,
1004 };
1005 let plane = match rt.block_on(async {
1006 substrate::Plane::open_with(
1007 &plane_dir,
1008 &s.model,
1009 provider,
1010 Some(tools_server),
1011 substrate::Hooks {
1012 progress: Some(Arc::clone(&live)),
1013 mcp: connected.clients.clone(),
1014 mcp_tools: connected.tools.clone(),
1015 format: Some(format),
1016 present: present.clone(),
1017 },
1018 )
1019 }) {
1020 Ok(p) => p,
1021 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1022 };
1023 let mut notices: Vec<String> = elsewhere
1024 .replaced_journal
1025 .iter()
1026 .chain(swept.iter())
1027 .chain(plane.replaced_journal.iter())
1028 .map(|p| {
1029 format!(
1030 "the journal could not be read by this build and was moved to {}; past runs stay in git",
1031 p.display()
1032 )
1033 })
1034 .chain(connected.notices.iter().cloned())
1035 .chain(sandbox_notice.clone())
1036 .chain(prepare_notes.iter().cloned())
1037 .chain(resumed_from.as_ref().map(|(stage, last_call)| {
1038 format!(
1039 "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
1040 stage.map_or("a run", speccoder_core::stage::Stage::name),
1041 last_call
1042 .as_ref()
1043 .map(|c| format!(" ({c})"))
1044 .unwrap_or_default()
1045 )
1046 }))
1047 .collect();
1048
1049 let demand_red =
1052 run_config.acceptance && planning.is_none() && format.is_convergence(&bundle.task.phase);
1053 let start = match &interrupted {
1059 Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
1060 Ok(tree) => worktree::Checkpoint(tree),
1061 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1062 },
1063 None => match worktree::checkpoint(&wt_root, &tmp) {
1064 Ok(c) => c,
1065 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1066 },
1067 };
1068
1069 let began = std::time::Instant::now();
1070 let bug_ctx = super::bug::Ctx {
1075 rt: &rt,
1076 plane: &plane,
1077 project: &project,
1078 wt_root: &wt_root,
1079 wt_path: &wt_path,
1080 tmp: &tmp,
1081 policy: &policy,
1082 runner: &runner,
1083 state: &s.plane_dir,
1084 feature: &feature_name,
1085 task,
1086 bundle: &bundle_value,
1087 instructions: &instructions,
1088 live: &*live,
1089 approver: approver.as_deref(),
1090 held_out: run_config.held_out,
1091 max_tries: run_config.reproduce_tries,
1092 bug_test: run_config.bug_test.as_deref(),
1093 start: &start,
1094 };
1095 let mut bug_fix = match super::bug::phase(&s.plane_dir, &feature_name, task, &project, format) {
1096 Err(e) => return fail(CommandStatus::NeedsHuman, e),
1097 Ok(None) => None,
1098 Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
1099 Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
1100 match super::bug::reproduce(&bug_ctx, *b) {
1101 Ok(f) => Some(f),
1102 Err(stop) => return *stop,
1103 }
1104 }
1105 Ok(Some(super::bug::Phase::Reproduce(_))) => None,
1106 };
1107 let bug_candidate = bug_fix.as_ref().and_then(|f| {
1108 f.bug.candidate.clone().and_then(|c| {
1109 crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
1110 .candidate_patch
1111 .map(|d| (d, c))
1112 })
1113 });
1114 let test_layout = crate::layout::detect(&project, &check_argvs);
1117 let test_layout_text = test_layout.text();
1118 let story_scenarios = match &bug_fix {
1119 Some(f) => super::bug::scenarios(&f.bug),
1120 None => format.scenarios(&snap, story_task.as_deref().unwrap_or(task)),
1121 };
1122 let rules = format.rules().judged;
1128 let whole_unit = format.kind() == speccoder_core::spec::FormatKind::Openspec;
1129 let story_key: Option<String> = if bug_fix.is_some() || story_scenarios.is_empty() {
1132 None
1133 } else {
1134 acceptance_story
1135 .clone()
1136 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone()))
1137 .or_else(|| whole_unit.then(|| feature_name.clone()))
1138 };
1139 let parsed_tasks = snap.feature.as_ref().and_then(|f| f.tasks.as_ref());
1140 let story_members: Vec<&speccoder_core::snapshot::Task> = match (&story_key, parsed_tasks) {
1141 (Some(key), Some(pt)) => pt
1142 .tasks
1143 .iter()
1144 .filter(|t| whole_unit || t.story.as_deref() == Some(key.as_str()))
1145 .collect(),
1146 _ => Vec::new(),
1147 };
1148 let judged_by: Option<speccoder_core::story::Judged> = if bug_fix.is_some() {
1149 None
1150 } else if story_key.is_some() {
1151 let ids: Vec<String> = story_scenarios.iter().map(|s| s.id.clone()).collect();
1152 let st = super::story::story_tasks(&story_members, &unit_dir, &rules);
1153 speccoder_core::story::judged(&st, task, &ids).or_else(|| {
1154 acceptance_story
1155 .is_some()
1156 .then_some(speccoder_core::story::Judged::Progress)
1157 })
1158 } else {
1159 parsed_task
1160 .as_ref()
1161 .and_then(|t| super::acceptance::outside_story(t, &story_scenarios, &unit_dir, &rules))
1162 };
1163 let in_story =
1166 story_key.is_some() && judged_by.is_some() && run_config.acceptance && planning.is_none();
1167 let scenarios: Vec<speccoder_core::spec::ScenarioRef> = match &judged_by {
1168 Some(speccoder_core::story::Judged::Linked { scenarios: l }) if story_key.is_none() => {
1169 story_scenarios
1170 .iter()
1171 .filter(|s| l.contains(&s.id))
1172 .cloned()
1173 .collect()
1174 }
1175 _ => story_scenarios.clone(),
1176 };
1177 let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
1180 let mut review_reuse = None;
1181 let mut story_oracle: Option<super::story::StoryOracle> = None;
1186 let mut oracle_note = serde_json::Value::Null;
1188 let mut oracle_empty = false;
1190 let mut before_run = super::story::Run::default();
1191 if in_story && let Some(key) = story_key.as_deref() {
1192 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1193 match super::story::ensure(&super::story::Ensure {
1194 key,
1195 scenarios: &story_scenarios,
1196 feature: &feature_name,
1197 project: &project,
1198 wt_root: &wt_root,
1199 state,
1200 plane_dir: &s.plane_dir,
1203 runner: &runner,
1204 rt: &rt,
1205 plane: &plane,
1206 options,
1207 format,
1208 snapshot: &snap,
1209 fresh: options.fresh_acceptance,
1210 resuming: interrupted.is_some(),
1211 }) {
1212 Ok((oracle, wrote, said)) => {
1213 notices.extend(said);
1214 if wrote {
1215 notices.push(format!(
1216 "{key}'s oracle: {} test(s) for {} scenario(s), committed {}",
1217 oracle.tests.len(),
1218 oracle.scenarios.len(),
1219 oracle.commit.get(..7).unwrap_or(&oracle.commit)
1220 ));
1221 head_now = git::Git::at(&wt_root)
1224 .line(&["rev-parse", "HEAD"])
1225 .unwrap_or_default();
1226 match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
1229 Ok(b) => baseline = b,
1230 Err(e) => {
1231 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
1232 }
1233 }
1234 }
1235 before_run = interrupted
1238 .as_ref()
1239 .and_then(|m| m.story_before.clone())
1240 .and_then(|v| serde_json::from_value(v).ok())
1241 .unwrap_or_else(|| super::story::run(&project, &oracle, &runner));
1242 if !oracle.tests.is_empty() {
1246 oracle_note = serde_json::json!({
1247 "red": oracle
1248 .scenarios
1249 .iter()
1250 .filter(|s| {
1251 before_run.states.get(&s.id)
1252 != Some(&speccoder_core::story::ScenarioState::Green)
1253 && !oracle.untestable.contains_key(&s.id)
1254 })
1255 .map(|s| serde_json::json!({
1256 "id": s.id,
1257 "contract": oracle.contracts.get(&s.id),
1258 "tests": oracle
1259 .tests
1260 .iter()
1261 .filter(|t| t.scenarios.contains(&s.id))
1262 .map(|t| t.path.clone())
1263 .collect::<Vec<_>>(),
1264 }))
1265 .collect::<Vec<_>>(),
1266 "links": judged_by.as_ref().map(speccoder_core::story::Judged::links).unwrap_or_default(),
1267 "checkpoint": matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint)),
1268 });
1269 }
1270 if let Some((record, manifest)) = oracle.reviewed.clone() {
1272 review_frozen = Some((record, manifest));
1273 }
1274 if oracle.tests.is_empty() {
1275 oracle_empty = true;
1276 } else {
1277 story_oracle = Some(oracle);
1278 }
1279 }
1280 Err(r) => {
1281 stage(
1282 &*live,
1283 task,
1284 Stage::Red,
1285 StageState::Failed,
1286 Some(r.message().to_owned()),
1287 None,
1288 );
1289 let reason = format!(
1290 "the acceptance tests could not be written ({}); nothing was attempted",
1291 r.message()
1292 );
1293 let report = speccoder_core::RunReport {
1294 reason: Some(redact(&reason, secret)),
1295 notices: notices.clone(),
1296 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1297 };
1298 let code = r.code().map_or("ORACLE-UNWRITTEN", |c| c.name);
1299 return CommandResult::new(
1300 "run",
1301 CommandStatus::NeedsHuman,
1302 &redact(&reason, secret),
1303 )
1304 .with_code(code)
1305 .with_data(CommandData::Run(Box::new(report)));
1306 }
1307 }
1308 }
1309 if bug_fix.is_none()
1310 && run_config.acceptance
1311 && !options.fresh_acceptance
1312 && planning.is_none()
1313 && !in_story
1314 {
1315 match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
1316 Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
1317 Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
1318 "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
1319 )),
1320 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
1321 notices.push(format!("{req}: frozen oracle not present here — authoring"))
1322 }
1323 Some(crate::oracle::TaskReuse::Lapsed {
1324 req,
1325 frozen,
1326 current,
1327 }) => {
1328 if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
1329 live.event(&progress::Event::Oracle {
1330 step: progress::OracleEvent::Lapsed {
1331 unit: feature_name.clone(),
1332 requirement: req.clone(),
1333 frozen_wording_sha256: frozen,
1334 current_wording_sha256: current,
1335 },
1336 });
1337 }
1338 notices.push(format!(
1339 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
1340 ));
1341 }
1342 Some(crate::oracle::TaskReuse::Tampered(why)) => {
1343 return fail(
1344 CommandStatus::NeedsHuman,
1345 format!("{why}: the task stops for a person, nothing is ticked"),
1346 )
1347 .with_code("ORACLE-TAMPERED");
1348 }
1349 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
1350 return fail(
1351 CommandStatus::NeedsHuman,
1352 format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
1353 )
1354 .with_code("REVIEW-UNREADABLE");
1355 }
1356 None => {}
1357 }
1358 }
1359 let mut acceptance = if let Some(prepared) = interrupted
1360 .as_ref()
1361 .and_then(|m| m.acceptance.clone())
1362 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
1363 {
1364 let mut prepared = prepared;
1368 if let Some(sealed) = bug_fix
1369 .as_ref()
1370 .and_then(|f| f.record.held_out_sealed.as_ref())
1371 {
1372 prepared.held_out = Some(sealed.manifest_sha256.clone());
1373 prepared.report["held_out"] = serde_json::json!({
1374 "state": "on",
1375 "manifest": format!("sha256:{}", sealed.manifest_sha256),
1376 "kept": sealed.count,
1377 });
1378 }
1379 Some(prepared)
1380 } else if let Some(f) = &bug_fix {
1381 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1382 match super::bug::reuse(&bug_ctx, f) {
1383 Ok(a) => Some(a),
1384 Err(e) => {
1385 let _ = worktree::restore(&wt_root, &tmp, &start);
1386 stage(
1387 &*live,
1388 task,
1389 Stage::Red,
1390 StageState::Failed,
1391 Some(e.clone()),
1392 Some(began),
1393 );
1394 return fail(CommandStatus::NeedsHuman, e);
1395 }
1396 }
1397 } else if let Some(reuse) = review_reuse.take() {
1398 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1401 let crate::oracle::TaskReuse::Ready {
1402 record,
1403 files,
1404 tests,
1405 manifest,
1406 } = reuse
1407 else {
1408 return fail(CommandStatus::Error, "no frozen oracle".to_owned());
1409 };
1410 match reuse_pre_frozen(
1411 &project,
1412 &record,
1413 &files,
1414 &tests,
1415 &runner,
1416 RedRule::RunTime,
1417 Some(&SealedCheck {
1418 state: &s.plane_dir,
1419 unit: &feature_name,
1420 wt_root: &wt_root,
1421 tmp: &tmp,
1422 policy: &policy,
1423 }),
1424 ) {
1425 Ok(mut a) => {
1426 a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
1427 notices.push(format!(
1428 "acceptance tests frozen at review by {} ({}) for {}: no author call",
1429 record.decided.by, record.decided.via, record.requirement
1430 ));
1431 review_frozen = Some((*record, manifest));
1432 Some(a)
1433 }
1434 Err(e) => {
1435 let _ = worktree::restore(&wt_root, &tmp, &start);
1436 stage(
1437 &*live,
1438 task,
1439 Stage::Red,
1440 StageState::Failed,
1441 Some(e.clone()),
1442 Some(began),
1443 );
1444 return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
1445 }
1446 }
1447 } else if run_config.acceptance
1448 && planning.is_none()
1449 && !in_story
1450 && format.acceptance_testable(&bundle)
1451 {
1452 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1453 Some(prepare_acceptance(&AcceptanceSetup {
1454 rt: &rt,
1455 plane: &plane,
1456 project: &project,
1457 worktree: &wt_root,
1458 tmp: &tmp,
1459 policy: &policy,
1460 feature: &feature_name,
1461 task,
1462 bundle: bundle_value.clone(),
1463 scenarios: &scenarios,
1464 instructions: &instructions,
1465 runner: &runner,
1466 layout: Some(&test_layout),
1467 }))
1468 } else {
1469 None
1470 };
1471 if review_frozen.is_none()
1472 && acceptance
1473 .as_ref()
1474 .is_some_and(|a| a.report["source"] == "review")
1475 && let Some(crate::oracle::TaskReuse::Ready {
1476 record, manifest, ..
1477 }) = review_reuse.take()
1478 {
1479 review_frozen = Some((*record, manifest));
1480 }
1481 let held_out_store = match (&bug_fix, &review_frozen) {
1484 (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1485 (None, Some((r, _))) => {
1486 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1487 }
1488 (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1489 };
1490 if let Some(a) = acceptance
1491 .as_mut()
1492 .filter(|a| a.report.get("held_out").is_none())
1493 {
1494 if !run_config.held_out {
1495 a.report["held_out"] = serde_json::json!({ "state": "off" });
1496 } else if a.argv.is_none() {
1497 a.report["held_out"] =
1498 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1499 } else if !a.red {
1500 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1501 } else if scenarios.is_empty() {
1502 a.report["held_out"] =
1503 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1504 } else {
1505 let (held, sha) = prepare_held_out(
1506 &AcceptanceSetup {
1507 rt: &rt,
1508 plane: &plane,
1509 project: &project,
1510 worktree: &wt_root,
1511 tmp: &tmp,
1512 policy: &policy,
1513 feature: &feature_name,
1514 task,
1515 bundle: bundle_value.clone(),
1516 scenarios: &scenarios,
1517 instructions: &instructions,
1518 runner: &runner,
1519 layout: Some(&test_layout),
1520 },
1521 a,
1522 &scenarios,
1523 &held_out_store,
1524 false,
1525 );
1526 a.report["held_out"] = held;
1527 a.held_out = sha;
1528 }
1529 }
1530 if run_config.held_out && !run_config.acceptance {
1531 notices.push(
1532 "[acceptance] held_out has no effect while generate = false: no held-out tests"
1533 .to_owned(),
1534 );
1535 }
1536 match &acceptance {
1537 None => stage(
1538 &*live,
1539 task,
1540 Stage::Red,
1541 StageState::Skipped,
1542 Some(if planning.is_some() {
1543 "planning work: design documents".to_owned()
1544 } else if in_story {
1545 "judged by its story's oracle".to_owned()
1546 } else {
1547 "no acceptance examples".to_owned()
1548 }),
1549 None,
1550 ),
1551 Some(a) if a.red => stage(
1552 &*live,
1553 task,
1554 Stage::Red,
1555 StageState::Passed,
1556 a.red_class.map(class_word),
1557 Some(began),
1558 ),
1559 Some(a) => stage(
1560 &*live,
1561 task,
1562 Stage::Red,
1563 StageState::Failed,
1564 Some(a.red_class.map_or_else(
1565 || "not demonstrated".to_owned(),
1566 |c| format!("not demonstrated ({})", class_word(c)),
1567 )),
1568 Some(began),
1569 ),
1570 }
1571
1572 if let Some(why) = acceptance
1576 .as_ref()
1577 .and_then(|a| a.generation_failed.clone())
1578 {
1579 let reason = format!(
1580 "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"
1581 );
1582 let _ = worktree::restore(&wt_root, &tmp, &start);
1583 let report = RunReport {
1584 reason: Some(reason.clone()),
1585 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1586 set_aside,
1587 notices: notices.clone(),
1588 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1589 };
1590 live.event(&finished_event(
1591 task,
1592 &report,
1593 "acceptance_failed",
1594 false,
1595 redact(&reason, secret),
1596 ));
1597 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1598 .with_code("ACCEPTANCE-UNWRITTEN")
1599 .with_data(CommandData::Run(Box::new(report)));
1600 }
1601
1602 if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
1603 let why = undemonstrated_why(acceptance.as_ref());
1604 let reason = format!(
1605 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1606 );
1607 let saved = attempts::save(
1609 &wt_root,
1610 &tmp,
1611 &feature_name,
1612 task,
1613 &attempts::Record {
1614 run: None,
1615 outcome: "not_demonstrated",
1616 reason: &reason,
1617 pending: None,
1618 usage: None,
1619 story_open: &[],
1620 },
1621 )
1622 .ok()
1623 .map(|s| s.reference);
1624 let _ = worktree::restore(&wt_root, &tmp, &start);
1625 let report = RunReport {
1626 reason: Some(reason.clone()),
1627 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1628 saved_attempt: saved,
1629 set_aside,
1630 notices: notices.clone(),
1631 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1632 };
1633 live.event(&finished_event(
1634 task,
1635 &report,
1636 "not_demonstrated",
1637 false,
1638 redact(&reason, secret),
1639 ));
1640 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1641 .with_data(CommandData::Run(Box::new(report)));
1642 }
1643
1644 let before = match &interrupted {
1647 Some(m) => worktree::Checkpoint(m.before.clone()),
1648 None => match worktree::checkpoint(&wt_root, &tmp) {
1649 Ok(c) => c,
1650 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1651 },
1652 };
1653 let acceptance_saved = acceptance
1654 .as_ref()
1655 .and_then(|a| serde_json::to_value(a).ok());
1656 let frozen: Vec<String> = match worktree::files(&wt_root, &before) {
1658 Ok(f) => f,
1659 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1660 }
1661 .into_iter()
1662 .filter(|p| tools::paths::is_test_path(p))
1663 .collect();
1664 let mut usage: Option<serde_json::Value> = None;
1665 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1668 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1670 let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1672 let mut refused_account = false;
1674 let (mut report, attempt_status) = loop {
1675 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1676 let mut digest: Option<serde_json::Value> = None;
1677 let mut attempt_no = interrupted
1680 .as_ref()
1681 .filter(|m| m.sample == samples.len())
1682 .map_or(1, |m| m.attempt.max(1));
1683 let (mut report, attempt_status) = loop {
1684 let hidden: Vec<String> = options
1686 .session
1687 .as_ref()
1688 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1689 .unwrap_or_default();
1690 let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1696 Ok(_) if autopilot => (Vec::new(), Vec::new()),
1699 Ok(mut d) => {
1700 let once = d.take_once(&feature_name, task, this_run.as_str());
1701 if !once.is_empty()
1702 && let Err(e) = d.save(&s.plane_dir)
1703 {
1704 return fail(CommandStatus::Error, e);
1705 }
1706 (d.for_run(&feature_name, task, this_run.as_str()), once)
1707 }
1708 Err(e) => return fail(CommandStatus::ConfigError, e),
1709 };
1710 let run_approved: Vec<crate::tools::policy::Object> = options
1712 .run_grants
1713 .lock()
1714 .map(|g| g.clone())
1715 .unwrap_or_default();
1716 let input = substrate::attempt::input(
1718 with_oracle(&bundle_value, &oracle_note),
1719 &substrate::attempt::Settings {
1720 checks: &check_argvs,
1721 allow: &run_config.allow,
1722 allow_prefix: &run_config.allow_prefix,
1723 max_turns: run_config.max_turns,
1724 max_tool_calls: run_config.max_tool_calls,
1725 reasoning: s.model.reasoning_summary,
1726 frozen: &frozen,
1727 approved: &task_grants,
1728 rejected: &decisions.rejected,
1729 run_approved: &run_approved,
1730 approved_once: &approved_once,
1731 nudges: &decisions.nudges,
1732 mcp: &connected.tools,
1733 root: &wt_root_canonical,
1734 frozen_additions: run_config.frozen_additions,
1735 asks: if options.lane.is_some() {
1738 "stop"
1739 } else {
1740 "tasks"
1741 },
1742 wait_secs: run_config.approval_wait.as_secs(),
1743 network_allow: &sandbox_config.network_allow,
1744 network_allow_prefix: &sandbox_config.network_allow_prefix,
1745 run_id: this_run.as_str(),
1746 asked: options.asked_now(),
1747 budget: run_config.approval_budget,
1748 prompts: approver.is_some() && options.lane.is_none(),
1749 in_place: options.session.is_some(),
1750 hidden: &hidden,
1751 autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1752 planning: planning.as_deref(),
1753 },
1754 digest.as_ref(),
1755 attempt_instructions,
1756 (!test_layout.runners.is_empty()).then_some(test_layout_text.as_str()),
1758 );
1759 let attempt_began = std::time::Instant::now();
1760 let attempt_detail = if run_config.best_of > 1 {
1761 format!(
1762 "{attempt_no}/{} · sample {}/{}",
1763 run_config.max_attempts,
1764 samples.len() + 1,
1765 run_config.best_of
1766 )
1767 } else {
1768 format!("{attempt_no}/{}", run_config.max_attempts)
1769 };
1770 stage(
1771 &*live,
1772 task,
1773 Stage::Attempt,
1774 StageState::Started,
1775 Some(attempt_detail.clone()),
1776 None,
1777 );
1778 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1781 let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1784 let held: Vec<String> = bug_fix
1785 .as_ref()
1786 .and_then(|f| {
1787 f.bug
1788 .held_out_sealed
1789 .clone()
1790 .map(|h| (f.bug.unit.clone(), h))
1791 })
1792 .and_then(|(unit, h)| {
1793 crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1794 })
1795 .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1796 .unwrap_or_default();
1797 match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1798 Ok(x) => x,
1799 Err(stop) => return *stop,
1800 }
1801 } else if let Some(exec) = &executor {
1802 let allow: Vec<Vec<String>> = check_argvs
1803 .iter()
1804 .chain(&run_config.allow)
1805 .cloned()
1806 .collect();
1807 let prompt = external::prompt_in(
1808 format.wording(),
1809 &with_oracle(&bundle_value, &oracle_note),
1810 digest.as_ref(),
1811 );
1812 let withheld = acceptance
1813 .as_ref()
1814 .and_then(|a| a.held_out.as_ref())
1815 .map(|_| held_out_store.as_path());
1816 if withheld.is_some()
1817 && !notices
1818 .iter()
1819 .any(|n| n.starts_with("held-out set withheld"))
1820 {
1821 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());
1822 }
1823 let _ = allow;
1824 let o = match &exec.kind {
1825 external::Kind::Command(argv) => {
1826 external::run(argv, exec.timeout, &wt_root, &prompt, &tmp)
1827 }
1828 external::Kind::Acp(agent) => {
1829 let policy = match substrate::attempt::policy_of(
1832 &input.peek()["settings"],
1833 format.rules(),
1834 ) {
1835 Ok(p) => p,
1836 Err(e) => return fail(CommandStatus::RunFailed, e.to_string()),
1837 };
1838 let tools = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
1839 Ok(t) => t.with_runner(runner.clone()),
1840 Err(e) => {
1841 return fail(
1842 CommandStatus::Error,
1843 format!("{}: {e}", wt_root.display()),
1844 );
1845 }
1846 };
1847 let governor = Arc::new(crate::acp::govern::Governor::new(
1848 crate::acp::govern::Setup {
1849 root: std::fs::canonicalize(&wt_root)
1850 .unwrap_or_else(|_| wt_root.clone()),
1851 tools,
1852 policy,
1853 approver: approver.clone().filter(|_| options.lane.is_none()),
1855 present: present.clone(),
1856 task: format!("{feature_name}/{task}"),
1857 task_title: parsed_task
1858 .as_ref()
1859 .map(|t| t.title.clone())
1860 .unwrap_or_default(),
1861 run: Some(this_run.to_string()),
1862 frozen_additions: run_config.frozen_additions,
1863 run_approved: run_approved.clone(),
1864 approved_once: approved_once.clone(),
1865 progress: Some((task.to_owned(), Arc::clone(&live))),
1866 copied: copied_files.clone(),
1867 agent: agent.id.clone(),
1868 },
1869 ));
1870 let a = external::run_acp(
1871 exec,
1872 agent,
1873 &wt_root,
1874 &prompt,
1875 &runner,
1876 governor,
1877 Some((task.to_owned(), Arc::clone(&live))),
1878 );
1879 if let Some(ways) = &a.sign_in {
1880 return login(fail, &a.agent, agent, ways);
1881 }
1882 for n in a.outcome.output["acp"]["notices"]
1883 .as_array()
1884 .into_iter()
1885 .flatten()
1886 .filter_map(serde_json::Value::as_str)
1887 {
1888 notices.push(format!("{}: {n}", a.agent));
1889 }
1890 a.outcome
1891 }
1892 };
1893 (o.run_id, true, o.output)
1894 } else {
1895 let key = match &interrupted {
1897 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
1898 m.key.clone()
1899 }
1900 _ => resume::new_key(&feature_name, task),
1901 };
1902 let _ = resume::save(
1903 &marker_path,
1904 &resume::Marker {
1905 key: key.clone(),
1906 before: before.0.clone(),
1907 head: head_now.clone(),
1908 attempt: attempt_no,
1909 sample: samples.len(),
1910 acceptance: acceptance_saved.clone(),
1911 story_before: story_oracle
1912 .as_ref()
1913 .and_then(|_| serde_json::to_value(&before_run).ok()),
1914 run_id: Some(this_run.to_string()),
1915 asked: options.asked_now(),
1916 run_grants: run_approved.clone(),
1917 },
1918 );
1919 marker_guard.arm();
1920 let outcome = match rt.block_on(plane.attempt_keyed(
1921 &feature_name,
1922 task,
1923 input,
1924 &key,
1925 )) {
1926 Ok((o, resumed)) => {
1927 if resumed {
1928 notices.push(format!(
1929 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
1930 o.run_id
1931 ));
1932 }
1933 o
1934 }
1935 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1936 };
1937 let context = crate::approvals::PresentContext {
1941 root: Some(wt_root.clone()),
1942 ..present.clone()
1943 };
1944 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
1945 outcome,
1946 approver.as_deref(),
1947 &context,
1948 )) {
1949 Ok(x) => {
1950 notices.extend(crate::actions::allow_always_inline(
1953 path,
1954 &feature_name,
1955 &x.always,
1956 state,
1957 ));
1958 (x.outcome, x.waiting)
1959 }
1960 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1961 };
1962 if let Some(task_row) = waiting {
1963 marker_guard.keep();
1966 let action = &task_row.justification.proposed_action;
1967 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
1968 report.judged.clone_from(&judged_by);
1969 report.run_id = outcome.run_id.to_string();
1970 report.notices = notices;
1971 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
1972 report.tool_calls.push(serde_json::json!({
1974 "tool": action["tool"],
1975 "arguments": action["arguments"],
1976 "decision": "approval",
1977 "reason": action["request"]["reason"],
1978 "object": action["request"]["object"],
1979 "waiting": true,
1980 "call": task_row.id.to_hex(),
1981 }));
1982 report.pending = Some(serde_json::json!({
1983 "tool": action["tool"],
1984 "arguments": action["arguments"],
1985 "reason": action["request"]["reason"],
1986 "object": action["request"]["object"],
1987 "request": action["request"],
1988 "call": task_row.id.to_hex(),
1989 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
1990 "waiting": true,
1991 "batched": action["request"]["batched"],
1992 }));
1993 if let (Some(n), Ok(mut asked)) = (
1996 action["request"]["asked"]
1997 .as_u64()
1998 .and_then(|n| u32::try_from(n).ok()),
1999 options.asked.lock(),
2000 ) {
2001 *asked = (*asked).max(n);
2002 }
2003 report.reason = Some(format!(
2004 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
2005 task_row.justification.summary.peek()
2006 ));
2007 let (status, message) = status_and_message(&report, "needs_approval", false);
2008 live.event(&finished_event(
2009 task,
2010 &report,
2011 "needs_approval",
2012 false,
2013 redact(&message, secret),
2014 ));
2015 return CommandResult::new("run", status, &redact(&message, secret))
2016 .with_data(CommandData::Run(Box::new(report)));
2017 }
2018 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
2019 refused_account = outcome
2020 .failed_effect
2021 .as_ref()
2022 .is_some_and(agentplane::journal::FailedEffect::refuses_the_account);
2023 let mut output = outcome
2024 .output
2025 .as_ref()
2026 .map(|o| o.peek().clone())
2027 .unwrap_or(serde_json::Value::Null);
2028 if !succeeded && output["reason"].as_str().is_none() {
2029 if !output.is_object() {
2030 output = serde_json::json!({});
2031 }
2032 let reason = if rt.block_on(plane.holds_a_pre_054_decision(outcome.run_id)) {
2035 substrate::PRE_054_DECISION.to_owned()
2036 } else if let agentplane::prelude::RunStatus::Exhausted(limit) = &outcome.status
2037 {
2038 output["status"] = serde_json::json!("budget_exhausted");
2040 substrate::exhausted(limit)
2041 } else if let agentplane::prelude::RunStatus::Quarantined(why) = &outcome.status
2042 {
2043 output["status"] = serde_json::json!("quarantined");
2044 why.clone()
2045 } else {
2046 substrate::why_not(&outcome.status)
2047 };
2048 output["reason"] = serde_json::Value::String(redact(&reason, secret));
2049 }
2050 if output.get("usage").is_none_or(serde_json::Value::is_null)
2053 && let Ok(Some(used)) =
2054 rt.block_on(substrate::spent(&s.plane_dir, &outcome.run_id.to_string()))
2055 {
2056 output["usage"] = used;
2057 }
2058 (outcome.run_id.to_string(), succeeded, output)
2059 };
2060 if bug_candidate.is_some()
2061 && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
2062 {
2063 c.applied = true;
2064 }
2065 if let Some(argv) = run_config.format.as_ref().filter(|_| {
2068 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
2069 }) {
2070 use verify::process::CommandRunner as _;
2071 let ran = runner.run(&verify::process::Invocation {
2072 argv: argv.clone(),
2073 cwd: project.clone(),
2074 env: verify::process::scrubbed_env(std::env::vars()),
2075 timeout: std::time::Duration::from_secs(300),
2076 output_cap: verify::process::OUTPUT_CAP,
2077 });
2078 if ran.exit_code != Some(0) {
2079 notices.push(format!(
2080 "the formatter (`{}`) did not succeed",
2081 argv.join(" ")
2082 ));
2083 }
2084 }
2085 if prepares {
2089 let began = std::time::Instant::now();
2090 match crate::prepare::recheck(&crate::prepare::Prepare {
2091 worktree: &wt_root,
2092 project: &project,
2093 config: &worktree_config,
2094 runner: &runner,
2095 state: Some(state),
2096 }) {
2097 Ok(done) if done.is_empty() => {}
2098 Ok(done) => {
2099 stage(
2100 &*live,
2101 task,
2102 Stage::Prepare,
2103 StageState::Passed,
2104 Some(format!(
2105 "{}: the attempt changed its lockfile",
2106 done.join(", ")
2107 )),
2108 Some(began),
2109 );
2110 }
2111 Err(f) => {
2112 stage(
2113 &*live,
2114 task,
2115 Stage::Prepare,
2116 StageState::Failed,
2117 Some(f.message.clone()),
2118 Some(began),
2119 );
2120 let reason = redact(&f.message, secret);
2124 let saved = attempts::save(
2125 &wt_root,
2126 &tmp,
2127 &feature_name,
2128 task,
2129 &attempts::Record {
2130 run: (!run_id.is_empty()).then_some(run_id.as_str()),
2131 outcome: "prepare_failed",
2132 reason: &reason,
2133 pending: None,
2134 usage: output.get("usage"),
2135 story_open: &[],
2136 },
2137 )
2138 .and_then(|saved| {
2139 worktree::restore(&wt_root, &tmp, &before).map(|_| saved)
2140 });
2141 let message = match saved {
2142 Ok(saved) => format!(
2143 "{} — the attempt is saved as {} and undone",
2144 f.message, saved.reference
2145 ),
2146 Err(e) => format!("{} — saving the attempt failed: {}", f.message, e.0),
2147 };
2148 return fail(CommandStatus::ConfigError, message)
2149 .with_code("PREPARE-FAILED")
2150 .with_fix(f.fix)
2151 .with_commands(f.commands);
2152 }
2153 }
2154 }
2155 let after = match worktree::checkpoint(&wt_root, &tmp) {
2156 Ok(c) => c,
2157 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2158 };
2159 let raw_changes = match worktree::diff(&wt_root, &before, &after) {
2161 Ok(c) => c,
2162 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2163 };
2164 if let (Some(n), Ok(mut asked)) = (
2169 output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
2170 options.asked.lock(),
2171 ) {
2172 *asked = n;
2173 }
2174 let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
2175 let patch = match worktree::patch(&wt_root, &before, &after) {
2176 Ok(p) => p,
2177 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2178 };
2179 if let Some(name) = crate::include::added_secret(&patch) {
2182 let _ = worktree::restore(&wt_root, &tmp, &before);
2183 return fail(
2184 CommandStatus::NeedsHuman,
2185 format!(
2186 "the attempt's change adds the value of {name}, copied from your checkout ([worktree] include): undone, nothing judged or committed"
2187 ),
2188 )
2189 .with_code("SECRET-IN-DIFF");
2190 }
2191 let findings = attempt_findings(&Audited {
2192 wt_root: &wt_root,
2193 policy: &policy,
2194 judged_before: &judged_before,
2195 raw_changes: &raw_changes,
2196 patch: &patch,
2197 frozen: &frozen,
2198 frozen_additions: run_config.frozen_additions,
2199 approved_writes: &approved_writes,
2200 command_drift: &output["integrity"],
2201 });
2202 let changes = changes_of(raw_changes);
2203
2204 let quarantined = !succeeded && output["status"] == "quarantined";
2209 if quarantined {
2210 output["reason"] = serde_json::Value::String(
2211 "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(),
2212 );
2213 }
2214 let mut attempt_status = if succeeded {
2215 output["status"].as_str().unwrap_or("failed").to_owned()
2216 } else if quarantined {
2217 "interrupted".to_owned()
2218 } else if output["status"] == "budget_exhausted" {
2219 "budget_exhausted".to_owned()
2220 } else {
2221 "failed".to_owned()
2222 };
2223 if attempt_status == "completed" && changes.is_empty() && output.get("acp").is_some() {
2227 attempt_status = "blocked".to_owned();
2228 output["reason"] = serde_json::json!(format!(
2229 "{} ended its turn without changing anything",
2230 output["acp"]["agent"].as_str().unwrap_or("the agent")
2231 ));
2232 }
2233 let mut report = RunReport {
2234 run_id: run_id.clone(),
2235 model_report: output.get("report").cloned(),
2236 reason: output["reason"].as_str().map(str::to_owned),
2237 pending: output.get("pending").cloned(),
2238 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
2239 changes,
2240 usage: output.get("usage").cloned(),
2241 integrity: findings.clone(),
2242 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
2243 set_aside: set_aside.clone(),
2244 notices: notices.clone(),
2245 agent: external::agent_attempt(&output),
2246 judged: judged_by.clone(),
2247 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
2248 };
2249 if let Err(e) = record_inline_approvals(
2250 &s.plane_dir,
2251 &feature_name,
2252 task,
2253 &report.tool_calls,
2254 &options.run_grants,
2255 this_run.as_str(),
2256 ) {
2257 report.notices.push(format!(
2258 "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
2259 ));
2260 }
2261 for c in report
2263 .tool_calls
2264 .iter()
2265 .filter(|c| c["approved_by"] == "person")
2266 {
2267 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
2268 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
2269 }
2270 }
2271
2272 stage(
2273 &*live,
2274 task,
2275 Stage::Attempt,
2276 if attempt_status == "completed" {
2277 StageState::Passed
2278 } else {
2279 StageState::Failed
2280 },
2281 Some(if attempt_status == "completed" {
2282 attempt_detail
2283 } else {
2284 format!("{attempt_detail} {attempt_status}")
2285 }),
2286 Some(attempt_began),
2287 );
2288 if attempt_status == "integrity" {
2291 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2292 reasons: findings.clone(),
2293 });
2294 }
2295 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
2297 if judged {
2298 let began = std::time::Instant::now();
2299 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
2300 let oracle_files: Vec<String> = story_oracle
2301 .iter()
2302 .flat_map(|o| o.tests.iter().map(|t| t.path.clone()))
2303 .collect();
2304 if let Err(e) = judge_checks(
2305 &project,
2306 &runner,
2307 (&baseline, &oracle_files),
2308 acceptance.as_ref(),
2309 &findings,
2310 &mut report,
2311 ) {
2312 stage(
2313 &*live,
2314 task,
2315 Stage::Verify,
2316 StageState::Failed,
2317 None,
2318 Some(began),
2319 );
2320 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
2321 }
2322 if let Some(o) = &story_oracle {
2325 let touched = super::story::changed(&project, o);
2328 if !touched.is_empty() {
2329 let why = format!(
2330 "the story's oracle tests were changed: {}",
2331 touched.join(", ")
2332 );
2333 report.verdict = Some(match report.verdict.take() {
2334 Some(speccoder_core::verify::Verdict::Failed { mut reasons }) => {
2335 reasons.push(why);
2336 speccoder_core::verify::Verdict::Failed { reasons }
2337 }
2338 _ => speccoder_core::verify::Verdict::Failed { reasons: vec![why] },
2339 });
2340 }
2341 let after = super::story::states(&project, o, &runner);
2342 super::story::judge(
2343 o,
2344 &before_run.states,
2345 &after,
2346 judged_by
2347 .as_ref()
2348 .map(speccoder_core::story::Judged::links)
2349 .unwrap_or_default(),
2350 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint)),
2351 &mut report,
2352 );
2353 if let Some(speccoder_core::story::Progress::Verified { turned }) =
2355 &report.progress
2356 {
2357 report.acceptance = Some(super::story::evidence(o, turned, &before_run));
2358 }
2359 }
2360 if let Some(dir) = planning.as_ref().filter(|_| {
2364 matches!(
2365 report.verdict,
2366 Some(
2367 speccoder_core::verify::Verdict::Verified
2368 | speccoder_core::verify::Verdict::RegressionOnly
2369 )
2370 )
2371 }) {
2372 let inside = format!("{dir}/");
2373 let outside: Vec<&str> = report
2374 .changes
2375 .iter()
2376 .filter(|(p, _)| !p.starts_with(&inside))
2377 .map(|(p, _)| p.as_str())
2378 .collect();
2379 let reason = if !outside.is_empty() {
2380 Some(format!(
2381 "a planning task changed code: {}",
2382 outside.join(", ")
2383 ))
2384 } else if !report.changes.iter().any(|(_, kind)| kind != "deleted") {
2385 Some(format!("wrote no planning file under {inside}"))
2386 } else {
2387 None
2388 };
2389 if !outside.is_empty() {
2390 attempt_status = CHANGED_CODE.to_owned();
2392 report.attempt_status = CHANGED_CODE.to_owned();
2393 }
2394 if let Some(r) = reason {
2395 report.verdict =
2396 Some(speccoder_core::verify::Verdict::Failed { reasons: vec![r] });
2397 }
2398 }
2399 let otherwise_passing = attempt_status == "completed"
2403 && findings.is_empty()
2404 && matches!(
2405 report.verdict,
2406 Some(
2407 speccoder_core::verify::Verdict::Verified
2408 | speccoder_core::verify::Verdict::RegressionOnly
2409 )
2410 );
2411 match judge_collection(
2412 (&project, &wt_root, &tmp),
2413 &runner,
2414 otherwise_passing,
2415 &mut report,
2416 &*live,
2417 task,
2418 ) {
2419 Some(Collected::Unknown(why)) => {
2420 attempt_status = "collection_unknown".to_owned();
2421 report.verdict = None;
2422 report.reason = Some(why);
2423 }
2424 Some(Collected::Changed(finding)) => {
2426 report.integrity.push(finding.clone());
2427 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2428 reasons: vec![finding],
2429 });
2430 attempt_status = "integrity".to_owned();
2431 }
2432 None => {}
2433 }
2434 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
2435 stage_with_tail(
2436 &*live,
2437 task,
2438 Stage::Verify,
2439 if checks_held {
2440 StageState::Passed
2441 } else {
2442 StageState::Failed
2443 },
2444 None,
2445 Some(began),
2446 report
2447 .verification
2448 .as_ref()
2449 .map(failing_tail)
2450 .unwrap_or_default(),
2451 );
2452 }
2453 if judged || attempt_status == "integrity" {
2454 stage(
2455 &*live,
2456 task,
2457 Stage::Audit,
2458 if findings.is_empty() {
2459 StageState::Passed
2460 } else {
2461 StageState::Failed
2462 },
2463 findings
2464 .first()
2465 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
2466 None,
2467 );
2468 }
2469
2470 let visibly_passed = judged
2474 && findings.is_empty()
2475 && attempt_status == "completed"
2476 && matches!(
2477 report.verdict,
2478 Some(
2479 speccoder_core::verify::Verdict::Verified
2480 | speccoder_core::verify::Verdict::RegressionOnly
2481 )
2482 );
2483 let held = acceptance
2486 .as_ref()
2487 .and_then(|a| a.held_out.clone())
2488 .map(|sha| (held_out_store.clone(), sha))
2489 .or_else(|| {
2490 story_oracle
2491 .as_ref()
2492 .filter(|_| {
2493 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
2494 })
2495 .and_then(|o| o.held_out.as_ref())
2496 .map(|h| {
2497 (
2498 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &h.key),
2499 h.manifest_sha256.clone(),
2500 )
2501 })
2502 });
2503 let at_checkpoint =
2506 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint));
2507 if at_checkpoint
2508 && visibly_passed
2509 && held.is_none()
2510 && let Some(summary) = story_oracle
2511 .as_ref()
2512 .and_then(|o| o.held_out_summary.as_ref())
2513 {
2514 let entry = report
2515 .acceptance
2516 .get_or_insert_with(|| serde_json::json!({ "source": "authored" }));
2517 entry["held_out"] = match summary.state.as_str() {
2518 "off" => serde_json::json!({ "state": "off" }),
2519 _ => serde_json::json!({
2520 "state": "absent",
2521 "reason": summary.reason,
2522 "discarded": summary
2523 .discarded
2524 .iter()
2525 .map(|r| serde_json::json!({ "reason": r }))
2526 .collect::<Vec<_>>(),
2527 }),
2528 };
2529 }
2530 if acceptance
2531 .as_ref()
2532 .and_then(|a| a.held_out.as_ref())
2533 .is_none()
2534 && let (Some((_, sha)), Some(o)) = (&held, &story_oracle)
2535 && visibly_passed
2536 {
2537 let entry = report.acceptance.get_or_insert_with(
2538 || serde_json::json!({ "source": "authored", "oracle": o.commit }),
2539 );
2540 entry["held_out"] = serde_json::json!({
2541 "state": "on",
2542 "manifest": format!("sha256:{sha}"),
2543 "kept": o.held_out.as_ref().map_or(0, |h| h.count),
2544 });
2545 }
2546 if let Some((store, sha)) = held.filter(|_| visibly_passed) {
2547 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2548 let mut known = format!(
2549 "{}\n{}\n{}",
2550 bundle_value["story"].as_str().unwrap_or_default(),
2551 bundle_value["plan"].as_str().unwrap_or_default(),
2552 bundle_value["constitution"].as_str().unwrap_or_default()
2553 );
2554 for p in report
2555 .acceptance
2556 .as_ref()
2557 .and_then(|a| a["files"].as_array().cloned())
2558 .unwrap_or_default()
2559 {
2560 if let Some(p) = p.as_str() {
2561 known.push_str(
2562 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2563 );
2564 }
2565 }
2566 match judge_held_out(
2567 &store, &sha, &scenarios, &known, &wt_root, &project, &tmp, &before, &patch,
2568 &runner, &policy,
2569 ) {
2570 Ok(j) => {
2571 let failed_ids: Vec<String> =
2572 j.failed.iter().map(|s| s.id.clone()).collect();
2573 held_out_log.push(serde_json::json!({
2574 "attempt": held_out_log.len() + 1,
2575 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
2576 "scenarios": failed_ids,
2577 }));
2578 if let Some(v) = report.verdict.take() {
2579 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
2580 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
2581 && !j.failed.is_empty()
2582 && let Some(last) = reasons.last_mut()
2583 {
2584 *last = speccoder_core::retry::held_out_reason(&j.failed);
2585 }
2586 report.verdict = Some(v);
2587 }
2588 }
2589 Err((outcome, why)) => {
2590 held_out_log.push(serde_json::json!({
2591 "attempt": held_out_log.len() + 1,
2592 "outcome": outcome,
2593 }));
2594 report.verdict = None;
2595 report.reason = Some(why);
2596 attempt_status = "held_out".to_owned();
2597 }
2598 }
2599 }
2600 if let Some(fix) = bug_fix.as_mut().filter(|_| {
2603 attempt_status == "completed"
2604 && findings.is_empty()
2605 && matches!(
2606 report.verdict,
2607 Some(
2608 speccoder_core::verify::Verdict::Verified
2609 | speccoder_core::verify::Verdict::RegressionOnly
2610 )
2611 )
2612 }) {
2613 let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
2614 let judging = super::bug::Judging {
2615 before: &before,
2616 baseline: &baseline,
2617 verification: report.verification.as_ref(),
2618 base: report
2619 .verdict
2620 .clone()
2621 .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
2622 keep: &keep,
2623 };
2624 match super::bug::judge(&bug_ctx, fix, &judging) {
2625 Ok(j) => {
2626 report.verdict = Some(j.verdict);
2627 if let Some(weak) = j.weak {
2628 report.reason = Some(format!(
2629 "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
2630 weak.scenario, weak.rationale
2631 ));
2632 bug_weak = Some(weak);
2633 attempt_status = "repro_weak".to_owned();
2634 }
2635 }
2636 Err(e) => {
2637 report.integrity.push(e.clone());
2638 report.reason = Some(e);
2639 report.verdict = None;
2640 attempt_status = "integrity".to_owned();
2641 }
2642 }
2643 }
2644 if let Some(h) = report
2645 .acceptance
2646 .as_mut()
2647 .and_then(|a| a.get_mut("held_out"))
2648 .filter(|h| h["state"] == "on")
2649 {
2650 let failed = held_out_log
2651 .iter()
2652 .filter(|o| o["outcome"] == "failed")
2653 .count();
2654 h["attempts"] = serde_json::json!(held_out_log);
2655 h["gap"] = serde_json::json!(failed > 0);
2656 }
2657
2658 let passing = attempt_status == "completed"
2660 && matches!(
2661 report.verdict,
2662 Some(
2663 speccoder_core::verify::Verdict::Verified
2664 | speccoder_core::verify::Verdict::RegressionOnly
2665 )
2666 );
2667 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
2668 stage(
2669 &*live,
2670 task,
2671 Stage::Mutation,
2672 StageState::Skipped,
2673 Some(if mutation_config.is_none() {
2674 "not configured".to_owned()
2675 } else {
2676 "suite = false: for acceptance strength only".to_owned()
2677 }),
2678 None,
2679 );
2680 }
2681 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
2682 let began = std::time::Instant::now();
2683 stage(
2684 &*live,
2685 task,
2686 Stage::Mutation,
2687 StageState::Started,
2688 None,
2689 None,
2690 );
2691 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2692 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
2693 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
2694 if m.mode == mutation::Mode::Require
2695 && let Some(reason) = mutation::failure(&result)
2696 {
2697 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2698 reasons: vec![reason],
2699 });
2700 }
2701 report.mutation = Some(result);
2702 }
2703 let held = !matches!(
2704 report.verdict,
2705 Some(speccoder_core::verify::Verdict::Failed { .. })
2706 );
2707 stage(
2708 &*live,
2709 task,
2710 Stage::Mutation,
2711 if held {
2712 StageState::Passed
2713 } else {
2714 StageState::Failed
2715 },
2716 report
2717 .mutation
2718 .as_ref()
2719 .and_then(crate::mutation::trailer)
2720 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
2721 Some(began),
2722 );
2723 }
2724
2725 usage = add_usage(usage.as_ref(), report.usage.as_ref());
2726 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
2728 (report.verdict.clone(), report.verification.as_ref())
2729 else {
2730 break (report, attempt_status);
2731 };
2732 if attempt_status != "completed" {
2733 break (report, attempt_status);
2734 }
2735 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
2736 let mut d =
2737 speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
2738 d.uncollected = report
2739 .collection
2740 .as_ref()
2741 .map(speccoder_core::collection::Collection::retry_lines)
2742 .unwrap_or_default();
2743 let fingerprint = speccoder_core::retry::fingerprint(&d);
2744 let stop = if bug_candidate.is_some() {
2745 Some("a candidate is judged, never retried".to_owned())
2746 } else {
2747 retry_stop(
2748 &findings,
2749 verification,
2750 earlier.last().map(|e| e.fingerprint.as_str()),
2751 &fingerprint,
2752 attempt_no,
2753 run_config.max_attempts,
2754 )
2755 };
2756 if let Some(stop) = stop {
2757 report.retry_stop = Some(stop);
2758 break (report, attempt_status);
2759 }
2760 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2761 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
2762 break (report, attempt_status);
2763 }
2764 earlier.push(speccoder_core::EarlierAttempt {
2765 run_id: report.run_id.clone(),
2766 attempt_status: attempt_status.clone(),
2767 verdict: report.verdict.clone(),
2768 fingerprint,
2769 digest: d.clone(),
2770 usage: report.usage.clone(),
2771 });
2772 digest = serde_json::to_value(&d).ok();
2773 attempt_no += 1;
2774 };
2775 report.earlier = earlier;
2776 if run_config.best_of <= 1 {
2777 break (report, attempt_status);
2778 }
2779 let end = match worktree::checkpoint(&wt_root, &tmp) {
2780 Ok(c) => c,
2781 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2782 };
2783 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
2784 let summary = sample_summary(&report, &attempt_status, &patch);
2785 samples.push((report, attempt_status, end, summary));
2786 if samples.len() >= run_config.best_of as usize {
2787 let best = best_sample(&samples);
2788 let summaries: Vec<serde_json::Value> = samples
2789 .iter()
2790 .enumerate()
2791 .map(|(i, s)| {
2792 let mut v = s.3.clone();
2793 v["kept"] = serde_json::json!(i == best);
2794 v
2795 })
2796 .collect();
2797 let (mut chosen, status, end, _) = samples.swap_remove(best);
2798 if best != samples.len() {
2799 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
2801 return fail(CommandStatus::GitPrecondition, e.0);
2802 }
2803 }
2804 chosen.samples = summaries;
2805 break (chosen, status);
2806 }
2807 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2808 return fail(CommandStatus::GitPrecondition, e.0);
2809 }
2810 };
2811 report.usage = usage;
2812 {
2815 use speccoder_core::context::ContextKind as K;
2816 let part = |k: &str| {
2817 bundle_value[k]
2818 .as_str()
2819 .unwrap_or_default()
2820 .as_bytes()
2821 .to_vec()
2822 };
2823 let mut m = crate::context::Manifest::new();
2824 m.push(
2825 K::Constitution,
2826 "worktree",
2827 "/bundle/constitution",
2828 &part("constitution"),
2829 )
2830 .push(K::Plan, "worktree", "/bundle/plan", &part("plan"))
2831 .push(K::Story, "worktree", "/bundle/story", &part("story"))
2832 .push(
2833 K::Task,
2834 "worktree",
2835 "/bundle/task",
2836 serde_json::to_string(&bundle_value["task"])
2837 .unwrap_or_default()
2838 .as_bytes(),
2839 )
2840 .push(
2841 K::TestLayout,
2842 "worktree",
2843 "/test_layout",
2844 test_layout_text.as_bytes(),
2845 );
2846 report.context = Some(m.finish());
2847 }
2848 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
2850
2851 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
2855 let mut attempt_status = attempt_status;
2856 let verified = attempt_status == "completed"
2857 && matches!(
2858 report.verdict,
2859 Some(speccoder_core::verify::Verdict::Verified)
2860 );
2861 let visible: Vec<Vec<String>> = match acceptance
2864 .as_ref()
2865 .filter(|a| a.red)
2866 .and_then(|a| a.argv.clone())
2867 {
2868 Some(argv) => vec![argv],
2869 None => report
2870 .acceptance
2871 .as_ref()
2872 .filter(|a| a["oracle"].is_string() && a["red"] == true)
2873 .and_then(|a| a["tests"].as_array().cloned())
2874 .unwrap_or_default()
2875 .iter()
2876 .filter_map(|t| serde_json::from_value::<Vec<String>>(t["argv"].clone()).ok())
2877 .collect(),
2878 };
2879 let visible_argv = visible.first().cloned();
2880 if verified && !run_config.strength.enabled {
2881 stage(
2882 &*live,
2883 task,
2884 Stage::Strength,
2885 StageState::Skipped,
2886 Some("strength = false".to_owned()),
2887 None,
2888 );
2889 }
2890 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
2891 use speccoder_core::strength::{Gate, gate, over_strict};
2892 let began = std::time::Instant::now();
2893 stage(
2894 &*live,
2895 task,
2896 Stage::Strength,
2897 StageState::Started,
2898 None,
2899 None,
2900 );
2901 let fin = worktree::checkpoint(&wt_root, &tmp);
2902 let patch = fin
2903 .as_ref()
2904 .ok()
2905 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
2906 .unwrap_or_default();
2907 let mut notes = Vec::new();
2908 let held_set: Option<(std::path::PathBuf, String)> = acceptance
2911 .as_ref()
2912 .and_then(|a| a.held_out.clone())
2913 .map(|sha| (held_out_store.clone(), sha))
2914 .or_else(|| {
2915 story_oracle
2916 .as_ref()
2917 .filter(|_| {
2918 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
2919 })
2920 .and_then(|o| o.held_out.as_ref())
2921 .map(|h| {
2922 (
2923 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &h.key),
2924 h.manifest_sha256.clone(),
2925 )
2926 })
2927 });
2928 let held: Vec<(crate::held_out::HeldOutFile, String)> = match held_set {
2929 None => Vec::new(),
2930 Some((store, sha)) => match crate::held_out::load_verified(&store, &sha) {
2931 Ok((manifest, contents)) => manifest
2932 .files
2933 .into_iter()
2934 .zip(contents.into_iter().map(|(_, c)| c))
2935 .collect(),
2936 Err(t) => {
2937 notes.push(format!("held-out set not overlaid: {}", t.0));
2938 Vec::new()
2939 }
2940 },
2941 };
2942 let rulings = crate::strength::rulings(&strength_dir);
2943 let mut commands = if visible.is_empty() {
2944 vec![argv]
2945 } else {
2946 visible.clone()
2947 };
2948 let measure = |commands: &[Vec<String>]| {
2949 crate::strength::measure(
2950 &crate::strength::Setup {
2951 wt_root: &wt_root,
2952 project: &project,
2953 tmp: &tmp,
2954 runner: &runner,
2955 policy: &policy,
2956 acceptance: commands,
2957 held_out: &held,
2958 mutation: mutation_config.as_ref(),
2959 patch: &patch,
2960 config: &run_config.strength,
2961 keep: &strength_dir,
2962 rulings: &rulings,
2963 },
2964 |changed| {
2965 let input = substrate::near_miss::input(
2967 &bundle_value,
2968 changed,
2969 &crate::mutation::source_patch(&patch),
2970 run_config.strength.near_miss,
2971 run_config.strength.permitted,
2972 );
2973 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
2974 Ok(o) => {
2975 let out = o
2976 .output
2977 .as_ref()
2978 .map(|v| v.peek().clone())
2979 .unwrap_or_default();
2980 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
2981 && !out["answer"].is_null();
2982 crate::strength::Authored {
2983 answer: if ok {
2984 Ok(out["answer"].clone())
2985 } else {
2986 Err(format!(
2987 "the variant author did not answer ({})",
2988 crate::substrate::why_not(&o.status)
2989 ))
2990 },
2991 run_id: Some(o.run_id.to_string()),
2992 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
2993 }
2994 }
2995 Err(e) => crate::strength::Authored {
2996 answer: Err(redact(&e.0, secret)),
2997 run_id: None,
2998 usage: None,
2999 },
3000 }
3001 },
3002 )
3003 };
3004 let mut measured = measure(&commands);
3005 let min = run_config.strength.min;
3008 if let (Ok(a), Ok(fin)) = (&measured, &fin)
3009 && gate(min, &a.combined, false) == Gate::Failed
3010 && over_strict(&a.permitted).is_empty()
3011 {
3012 let visible_tests: Vec<(String, String)> = report
3013 .acceptance
3014 .as_ref()
3015 .and_then(|a| a["files"].as_array().cloned())
3016 .unwrap_or_default()
3017 .iter()
3018 .filter_map(|p| p.as_str())
3019 .map(|p| {
3020 (
3021 p.to_owned(),
3022 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
3023 )
3024 })
3025 .collect();
3026 let setup = AcceptanceSetup {
3027 rt: &rt,
3028 plane: &plane,
3029 project: &project,
3030 worktree: &wt_root,
3031 tmp: &tmp,
3032 policy: &policy,
3033 feature: &feature_name,
3034 task,
3035 bundle: bundle_value.clone(),
3036 scenarios: &scenarios,
3037 instructions: &instructions,
3038 runner: &runner,
3039 layout: Some(&test_layout),
3040 };
3041 let weak = crate::strength::weak(a);
3042 match strengthen(&setup, &visible_tests, weak, &before, fin) {
3043 Ok(added) => {
3044 if let Some(acc) = report.acceptance.as_mut() {
3045 if let Some(files) = acc["files"].as_array_mut() {
3046 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
3047 }
3048 acc["strengthened"] = serde_json::json!({
3049 "files": added.files,
3050 "argv": added.argv,
3051 "run_id": added.report["run_id"],
3052 "usage": added.report["usage"],
3053 });
3054 }
3055 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
3056 commands.push(added.argv);
3057 let mut again = measure(&commands);
3058 if let Ok(b) = again.as_mut() {
3059 b.gate = gate(min, &b.combined, true);
3060 b.notes.insert(
3061 0,
3062 "strengthened once with added acceptance tests".to_owned(),
3063 );
3064 }
3065 measured = again;
3066 }
3067 Err(why) => {
3068 if let Ok(a) = measured.as_mut() {
3069 a.notes
3070 .insert(0, format!("could not strengthen the set: {why}"));
3071 }
3072 }
3073 }
3074 }
3075 match measured {
3076 Ok(mut a) => {
3077 if a.gate == Gate::Off {
3078 a.gate = gate(min, &a.combined, false);
3079 }
3080 a.notes.extend(notes);
3081 let findings = over_strict(&a.permitted);
3082 if !findings.is_empty() {
3083 attempt_status = "over_strict".to_owned();
3084 report.reason = Some(format!(
3085 "an acceptance test rejects an implementation the spec appears to allow: {}",
3086 findings
3087 .iter()
3088 .map(|p| format!("{} ({})", p.scenario, p.rationale))
3089 .collect::<Vec<_>>()
3090 .join("; ")
3091 ));
3092 } else if a.gate == Gate::Failed {
3093 attempt_status = "acceptance_weak".to_owned();
3094 report.reason = Some(format!(
3095 "acceptance set too weak: strength {} is below min_strength {}{}",
3096 speccoder_core::strength::summary(&a.combined, None),
3097 min.unwrap_or_default(),
3098 a.notes
3099 .first()
3100 .map(|n| format!(" ({n})"))
3101 .unwrap_or_default()
3102 ));
3103 } else {
3104 crate::strength::release(&strength_dir);
3105 }
3106 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
3108 report.strength = Some(a);
3109 }
3110 Err(e) => {
3111 report.integrity.push(e.clone());
3112 report.reason = Some(e);
3113 report.verdict = None;
3114 attempt_status = "integrity".to_owned();
3115 }
3116 }
3117 report.attempt_status.clone_from(&attempt_status);
3118 let held_up = attempt_status == "completed";
3119 stage(
3120 &*live,
3121 task,
3122 Stage::Strength,
3123 if held_up {
3124 StageState::Passed
3125 } else {
3126 StageState::Failed
3127 },
3128 report.strength.as_ref().map(|a| {
3129 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
3130 }),
3131 Some(began),
3132 );
3133 }
3134
3135 let verdict_passed = attempt_status == "completed"
3139 && matches!(
3140 report.verdict,
3141 Some(
3142 speccoder_core::verify::Verdict::Verified
3143 | speccoder_core::verify::Verdict::RegressionOnly
3144 )
3145 );
3146 let mut suspected = false;
3147 if !run_config.monitor && verdict_passed {
3148 stage(
3149 &*live,
3150 task,
3151 Stage::Monitor,
3152 StageState::Skipped,
3153 Some("not enabled".to_owned()),
3154 None,
3155 );
3156 }
3157 if run_config.monitor && verdict_passed {
3158 let began = std::time::Instant::now();
3159 stage(
3160 &*live,
3161 task,
3162 Stage::Monitor,
3163 StageState::Started,
3164 None,
3165 None,
3166 );
3167 let patch = worktree::checkpoint(&wt_root, &tmp)
3168 .and_then(|now| worktree::patch(&wt_root, &start, &now))
3169 .unwrap_or_default();
3170 let calls: Vec<serde_json::Value> = report
3171 .tool_calls
3172 .iter()
3173 .map(|c| {
3174 let tool = c["tool"].as_str().unwrap_or_default();
3175 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
3176 })
3177 .collect();
3178 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
3179 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
3180 report.monitor = Some(monitor);
3181 suspected = suspicion;
3182 stage(
3183 &*live,
3184 task,
3185 Stage::Monitor,
3186 if suspected {
3187 StageState::Failed
3188 } else {
3189 StageState::Passed
3190 },
3191 suspected.then(|| "suspects gaming".to_owned()),
3192 Some(began),
3193 );
3194 }
3195
3196 let in_place_stop = options
3199 .session
3200 .as_ref()
3201 .filter(|_| verdict_passed && !suspected)
3202 .and_then(|s| {
3203 s.lock()
3204 .ok()
3205 .and_then(|s| s.check_before_tick(&policy).err())
3206 });
3207 if let Some(e) = &in_place_stop {
3208 report.reason = Some(e.line());
3209 report.verdict = None;
3210 attempt_status = e.code.to_lowercase().replace('-', "_");
3212 report.attempt_status.clone_from(&attempt_status);
3213 }
3214 let passed = verdict_passed && !suspected && in_place_stop.is_none();
3215 if passed
3219 && matches!(
3220 report.verdict,
3221 Some(speccoder_core::verify::Verdict::RegressionOnly)
3222 )
3223 {
3224 use speccoder_core::regression::{Regression, RegressionFacts, RegressionReason, decide};
3225 let facts = RegressionFacts {
3226 acceptance_on: run_config.acceptance,
3227 story: acceptance_story
3229 .clone()
3230 .or_else(|| bug_fix.as_ref().map(|f| f.bug.requirement.clone()))
3231 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone())),
3232 scenarios: scenarios.len(),
3233 skip: acceptance.as_ref().and_then(|a| a.skip_cause.clone()),
3234 red_class: acceptance.as_ref().and_then(|a| {
3236 if a.red
3237 && report
3238 .acceptance
3239 .as_ref()
3240 .is_some_and(|r| r["green_flaky"] == true)
3241 {
3242 Some(speccoder_core::verify::RedClass::Flaky)
3243 } else {
3244 a.red_class
3245 }
3246 }),
3247 };
3248 let reason = if planning.is_some() {
3249 RegressionReason::PlanningWork
3250 } else if in_story && oracle_empty {
3251 RegressionReason::AuthorWroteNone
3252 } else if in_story {
3253 match &report.progress {
3255 Some(
3257 speccoder_core::story::Progress::Closed
3258 | speccoder_core::story::Progress::InProgress,
3259 ) if story_oracle
3260 .as_ref()
3261 .is_some_and(super::story::StoryOracle::green_from_the_start) =>
3262 {
3263 RegressionReason::AlreadyPassing
3264 }
3265 Some(speccoder_core::story::Progress::Closed) => RegressionReason::StoryVerified,
3266 _ if super::story::unchecked(&report) => RegressionReason::RegressionUnchecked,
3267 _ => RegressionReason::InProgress,
3268 }
3269 } else {
3270 decide(&facts)
3271 };
3272 report.regression = Some(Regression::of(reason));
3273 }
3274 let recorded_from = report.integrity.len();
3275 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
3276 let began = std::time::Instant::now();
3277 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3278 let message = tick_message(&TickFacts {
3279 feature: &feature_name,
3280 task,
3281 title: &t.title,
3282 report: &report,
3283 scenarios: &story_scenarios,
3284 red: acceptance
3285 .as_ref()
3286 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red()))
3287 .or_else(|| story_red(&report)),
3288 checklists_overridden,
3289 extra: bug_fix
3290 .as_ref()
3291 .map(|f| {
3292 super::bug::trailers(
3293 f,
3294 &report
3295 .verdict
3296 .as_ref()
3297 .map(super::outcome::verdict_word)
3298 .unwrap_or_default(),
3299 &crate::strength::rulings(&strength_dir),
3300 )
3301 })
3302 .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
3304 .unwrap_or_default(),
3305 });
3306 match &options.session {
3307 None => {
3309 match format.tick(file, task, &t.line_hash) {
3310 Ok(()) => report.ticked = true,
3311 Err(e) => report.integrity.push(format!("tick refused: {e}")),
3312 }
3313 if report.ticked && run_config.commit_per_task {
3314 match commit_attempt(&wt_root, &message) {
3315 Ok(id) => report.commit = Some(id),
3316 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3317 }
3318 }
3319 }
3320 Some(session) => {
3323 let digest =
3324 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
3325 if let Ok(mut s) = session.lock() {
3326 let done = s.commit_and_tick(
3327 &plane_dir,
3328 file,
3329 &t.line_hash,
3330 &message,
3331 &digest,
3332 || format.tick(file, task, &t.line_hash),
3333 );
3334 report.ticked = done.ticked;
3335 report.commit = done.commit;
3336 report.integrity.extend(done.problems);
3337 }
3338 }
3339 }
3340 report.instructions = attempt_instructions
3341 .iter()
3342 .map(|i| speccoder_core::InstructionFile {
3343 path: i.path.clone(),
3344 sha256: i.sha256.clone(),
3345 bytes: i.bytes,
3346 cut: i.cut,
3347 })
3348 .collect();
3349 if let Some(commit) = report
3351 .commit
3352 .clone()
3353 .filter(|_| report.ticked && run_config.attest.enabled)
3354 {
3355 let verdict = report
3356 .verdict
3357 .as_ref()
3358 .map(super::outcome::verdict_word)
3359 .unwrap_or_default();
3360 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3361 let feature_dir = snap
3362 .feature
3363 .as_ref()
3364 .map(|f| f.feature.dir.clone())
3365 .unwrap_or_default();
3366 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
3367 let tick = crate::attest::Tick {
3368 repo: &wt_root,
3369 config: &config_text,
3370 attest: &run_config.attest,
3371 format: &format!("{:?}", format.kind()).to_lowercase(),
3372 feature: &feature_name,
3373 feature_dir: &feature_dir,
3374 task,
3375 title: &t.title,
3376 line_hash: &t.line_hash,
3377 section: t.story.as_deref(),
3378 section_text: bundle_value["story"].as_str().unwrap_or_default(),
3379 requirements: &requirements,
3380 commit: &commit,
3381 verdict: &verdict,
3382 sandbox: serde_json::json!({
3383 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3384 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3385 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3386 }),
3387 executor: serde_json::json!({
3388 "kind": if bug_candidate.is_some() { "candidate".to_owned() } else { executor.as_ref().map_or_else(|| "native".to_owned(), |e| e.kind.name().to_owned()) },
3389 "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
3390 }),
3391 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3392 bug: bug_fix
3393 .as_ref()
3394 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3395 };
3396 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3397 }
3398 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
3399 stage(
3400 &*live,
3401 task,
3402 Stage::Commit,
3403 if committed {
3404 StageState::Passed
3405 } else {
3406 StageState::Failed
3407 },
3408 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3409 Some(began),
3410 );
3411 }
3412 if passed && let Some(n) = &native {
3415 let began = std::time::Instant::now();
3416 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3417 let verdict = report
3418 .verdict
3419 .as_ref()
3420 .map(super::outcome::verdict_word)
3421 .unwrap_or_default();
3422 let message = tick_message(&TickFacts {
3423 feature: &feature_name,
3424 task,
3425 title: &n.title,
3426 report: &report,
3427 scenarios: &story_scenarios,
3428 red: acceptance
3429 .as_ref()
3430 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red()))
3431 .or_else(|| story_red(&report)),
3432 checklists_overridden: false,
3433 extra: bug_fix
3434 .as_ref()
3435 .map(|f| {
3436 super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
3437 })
3438 .unwrap_or_default(),
3439 });
3440 match commit_attempt(&wt_root, &message) {
3441 Ok(id) => report.commit = Some(id),
3442 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3443 }
3444 if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
3445 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3446 let tick = crate::attest::Tick {
3447 repo: &wt_root,
3448 config: &config_text,
3449 attest: &run_config.attest,
3450 format: &format!("{:?}", format.kind()).to_lowercase(),
3451 feature: &feature_name,
3452 feature_dir: &n.dir,
3453 task,
3454 title: &n.title,
3455 line_hash: "",
3456 section: None,
3457 section_text: &n.story,
3458 requirements: &[],
3459 commit: &commit,
3460 verdict: &verdict,
3461 sandbox: serde_json::json!({
3462 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3463 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3464 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3465 }),
3466 executor: serde_json::json!({
3467 "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
3468 }),
3469 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3470 bug: bug_fix
3471 .as_ref()
3472 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3473 };
3474 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3475 }
3476 stage(
3477 &*live,
3478 task,
3479 Stage::Commit,
3480 if report.commit.is_some() {
3481 StageState::Passed
3482 } else {
3483 StageState::Failed
3484 },
3485 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3486 Some(began),
3487 );
3488 }
3489
3490 let unrecorded = report.integrity[recorded_from..]
3493 .iter()
3494 .find(|m| m.starts_with("tick refused") || m.starts_with("commit failed"))
3495 .cloned();
3496 let passed = passed && unrecorded.is_none();
3497 if let Some(why) = unrecorded {
3498 attempt_status = "tick_failed".to_owned();
3499 report.ticked = false;
3500 report.reason = Some(why);
3501 }
3502 if attempt_status == "repro_weak"
3507 && let Some(weak) = &bug_weak
3508 && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
3509 {
3510 report
3511 .integrity
3512 .push(format!("holding the attempt failed: {e}"));
3513 }
3514 if attempt_status == "over_strict" || attempt_status == "repro_weak" {
3515 let findings = report
3516 .strength
3517 .as_ref()
3518 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
3519 .unwrap_or_default();
3520 if attempt_status == "over_strict"
3521 && let Err(e) = crate::strength::hold(&strength_dir, &findings)
3522 {
3523 report
3524 .integrity
3525 .push(format!("holding the attempt failed: {e}"));
3526 }
3527 marker_guard.keep();
3528 }
3529 if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
3532 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
3533 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
3534 let reason = redact(&reason, secret);
3535 let story_open: Vec<String> = match &report.progress {
3540 Some(speccoder_core::story::Progress::StoryOpen { scenarios }) => scenarios.clone(),
3541 _ if outcome == "blocked" => story_oracle
3542 .as_ref()
3543 .and_then(|o| {
3544 let named = report
3545 .reason
3546 .as_deref()?
3547 .split("frozen test `")
3548 .nth(1)?
3549 .split('`')
3550 .next()?
3551 .trim_start_matches("./")
3552 .to_owned();
3553 o.tests
3554 .iter()
3555 .find(|t| t.path == named)
3556 .map(|t| t.scenarios.clone())
3557 })
3558 .unwrap_or_default(),
3559 _ => Vec::new(),
3560 };
3561 let record = attempts::Record {
3562 run,
3563 outcome: &outcome,
3564 reason: &reason,
3565 pending: report.pending.as_ref(),
3566 usage: report.usage.as_ref(),
3567 story_open: &story_open,
3568 };
3569 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
3570 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
3571 {
3572 Ok(saved) => report.saved_attempt = Some(saved.reference),
3573 Err(e) => report
3574 .integrity
3575 .push(format!("saving the attempt failed: {}", e.0)),
3576 }
3577 }
3578
3579 let (status, mut message) = status_and_message(&report, &attempt_status, suspected);
3580 if status == CommandStatus::RunFailed
3583 && let Some(region) = s.model.region.as_deref()
3584 && report
3585 .reason
3586 .as_deref()
3587 .and_then(substrate::provider_refusal)
3588 .is_some()
3589 {
3590 message.push_str(&format!(
3591 " — Amazon Bedrock, region {region}, model {}",
3592 s.model.model
3593 ));
3594 }
3595 let refusal = |result: CommandResult| {
3598 if !result.has_explicit_code()
3599 && refused_account
3600 && result.status() == CommandStatus::RunFailed
3601 {
3602 result.with_code("PROVIDER-REFUSED").with_fix(
3603 substrate::model_config::provider_refused_fix(
3604 &s.model,
3605 crate::aws::detect_here().as_ref(),
3606 feature,
3607 ),
3608 )
3609 } else {
3610 result
3611 }
3612 };
3613 let offered = crate::ladder::offer_for(
3615 path,
3616 &worktree::project_state_dir(state, path),
3617 &format!("{feature_name}/{task}"),
3618 )
3619 .map(|o| progress::Event::LadderOffered {
3620 unit: o.unit,
3621 task: o.task,
3622 signals: o.signals.into_iter().map(|s| s.id).collect(),
3623 });
3624 if let Some(fix) = bug_fix.as_mut() {
3625 let done = refusal(super::bug::finished(
3626 &bug_ctx,
3627 fix,
3628 report,
3629 &attempt_status,
3630 passed,
3631 status,
3632 redact(&message, secret),
3633 ));
3634 if let Some(e) = &offered {
3635 live.event(e);
3636 }
3637 return done;
3638 }
3639 live.event(&finished_event(
3640 task,
3641 &report,
3642 &attempt_status,
3643 suspected,
3644 redact(&message, secret),
3645 ));
3646 if let Some(e) = &offered {
3647 live.event(e);
3648 }
3649 let code = collection_code(&report, &attempt_status).or_else(|| {
3651 matches!(
3652 report.progress,
3653 Some(speccoder_core::story::Progress::StoryOpen { .. })
3654 )
3655 .then_some("STORY-OPEN")
3656 });
3657 let story_open_fix: Vec<String> = match &report.progress {
3658 Some(speccoder_core::story::Progress::StoryOpen { scenarios }) if !report.ticked => {
3659 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3660 std::iter::once(format!("speccoder task nudge {task}{f} --note \"…\""))
3661 .chain(scenarios.iter().map(|id| {
3662 format!(
3663 "speccoder task rule {task}{f} --tests-wrong --scenario {id} --reason \"…\""
3664 )
3665 }))
3666 .collect()
3667 }
3668 _ => Vec::new(),
3669 };
3670 let result = CommandResult::new("run", status, &redact(&message, secret));
3671 let result = if story_open_fix.is_empty() {
3672 result
3673 } else {
3674 result.with_fix(story_open_fix)
3675 };
3676 match code {
3677 Some(c) => result.with_code(c),
3678 None => refusal(result),
3679 }
3680 .with_data(CommandData::Run(Box::new(report)))
3681}
3682
3683fn collection_code(
3686 report: &speccoder_core::RunReport,
3687 attempt_status: &str,
3688) -> Option<&'static str> {
3689 if attempt_status == "collection_unknown" {
3690 return Some("TESTS-COLLECTION-UNKNOWN");
3691 }
3692 match &report.verdict {
3693 Some(speccoder_core::verify::Verdict::Failed { reasons })
3694 if reasons
3695 .first()
3696 .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
3697 {
3698 Some("TESTS-NOT-COLLECTED")
3699 }
3700 _ => None,
3701 }
3702}
3703
3704enum Collected {
3710 Unknown(String),
3712 Changed(String),
3714}
3715
3716fn judge_collection(
3717 dirs: (&Path, &Path, &Path),
3718 runner: &sandbox::Runner,
3719 otherwise_passing: bool,
3720 report: &mut speccoder_core::RunReport,
3721 live: &dyn progress::Progress,
3722 task: &str,
3723) -> Option<Collected> {
3724 let (project, wt_root, tmp) = dirs;
3725 let before = worktree::checkpoint(wt_root, tmp).ok();
3727 let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
3728 .ok()
3729 .and_then(|t| crate::verify::config::parse(&t).ok())
3730 .unwrap_or_default()
3731 .into_iter()
3732 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
3733 .collect::<Vec<_>>();
3734 let results = report
3735 .verification
3736 .as_ref()
3737 .map(|v| v.checks.clone())
3738 .unwrap_or_default();
3739 let c = crate::collection::judge(&crate::collection::Input {
3740 root: project,
3741 changes: &report.changes,
3742 checks: &checks,
3743 results: &results,
3744 runner,
3745 listings: otherwise_passing,
3746 })?;
3747 if !c.listing.is_empty()
3748 && let Some(before) = &before
3749 {
3750 let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
3751 .and_then(|after| worktree::diff(wt_root, before, &after))
3752 .map(|d| {
3753 d.into_iter()
3754 .map(|c| c.path)
3755 .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
3756 .collect()
3757 })
3758 .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
3759 if !changed.is_empty() {
3760 report.collection = Some(c);
3761 return Some(Collected::Changed(format!(
3762 "integrity: a test listing changed the worktree: {}",
3763 changed.join(", ")
3764 )));
3765 }
3766 }
3767 live.event(&progress::Event::CollectionChecked {
3768 task: task.to_owned(),
3769 files: c
3770 .files
3771 .iter()
3772 .map(|f| progress::CollectionFile {
3773 path: f.path.clone(),
3774 state: f.state,
3775 decided_by: f.decided_by,
3776 })
3777 .collect(),
3778 });
3779 let never = c.reasons();
3780 let unknown: Vec<String> = c
3781 .unknown()
3782 .map(|f| {
3783 format!(
3784 "speccoder cannot tell whether the runner ran `{}` ({})",
3785 f.path,
3786 f.why.as_deref().unwrap_or("no report or listing names it")
3787 )
3788 })
3789 .collect();
3790 report.collection = Some(c);
3791 if !never.is_empty() {
3792 let mut reasons = never;
3793 if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
3794 report.verdict.take()
3795 {
3796 reasons.extend(more);
3797 }
3798 report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
3799 return None;
3800 }
3801 (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
3802}
3803
3804fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
3807 let said = c
3808 .stderr_tail
3809 .lines()
3810 .chain(c.stdout_tail.lines())
3811 .map(str::trim)
3812 .find(|l| l.starts_with("error"))
3813 .map(|l| l.chars().take(200).collect::<String>());
3814 let fix = match said.as_deref() {
3815 Some(l) if l.contains("profile `speccoder` not found") => Some(
3816 "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
3817 ),
3818 Some(l) if l.contains("no tests to run") => Some(
3819 "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",
3820 ),
3821 Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
3822 "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
3823 ),
3824 _ => None,
3825 };
3826 match (said, fix) {
3827 (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
3828 (Some(s), None) => format!("{note} ({s})"),
3829 _ => note.to_owned(),
3830 }
3831}
3832
3833#[must_use]
3837pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
3838 use speccoder_core::verify::CheckStatus;
3839 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
3840 return None;
3841 }
3842 if let Some(note) = &c.note {
3843 return Some(with_cause(note, c));
3844 }
3845 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
3846 return None;
3847 }
3848 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
3849 let runner = c
3853 .argv
3854 .first()
3855 .map(|a| a.rsplit('/').next().unwrap_or(a))
3856 .is_some_and(|a| {
3857 matches!(
3858 a,
3859 "cargo"
3860 | "pytest"
3861 | "python"
3862 | "python3"
3863 | "uv"
3864 | "npm"
3865 | "npx"
3866 | "pnpm"
3867 | "yarn"
3868 | "bun"
3869 | "go"
3870 | "jest"
3871 | "vitest"
3872 | "mvn"
3873 | "gradle"
3874 | "dotnet"
3875 | "mix"
3876 | "swift"
3877 )
3878 });
3879 let cannot_start = matches!(c.exit_code, Some(126 | 127));
3880 let is_broken = match &summary {
3881 _ if cannot_start => true,
3882 Some(s) if s.build_failed => true,
3883 Some(s) => s.passed + s.failed + s.errors == 0,
3884 None => runner,
3885 };
3886 match is_broken {
3887 false => None,
3888 true => {
3889 let first = c
3890 .stderr_tail
3891 .lines()
3892 .chain(c.stdout_tail.lines())
3893 .map(str::trim)
3894 .find(|l| l.to_lowercase().contains("error"))
3895 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
3896 .unwrap_or("it failed without running a test")
3897 .chars()
3898 .take(160)
3899 .collect::<String>();
3900 Some(first)
3901 }
3902 }
3903}
3904
3905fn login(
3909 fail: impl FnOnce(CommandStatus, String) -> CommandResult,
3910 shown: &str,
3911 agent: &crate::acp::agent::Agent,
3912 ways: &[crate::acp::session::SignIn],
3913) -> CommandResult {
3914 let words: Vec<String> = ways
3915 .iter()
3916 .filter(|w| w.command.is_none())
3917 .map(|w| {
3918 format!(
3919 "→ {}",
3920 w.description.clone().unwrap_or_else(|| w.name.clone())
3921 )
3922 })
3923 .collect();
3924 let mut commands: Vec<Vec<String>> = ways
3925 .iter()
3926 .filter_map(|w| w.command.as_deref())
3927 .map(|c| c.split_whitespace().map(str::to_owned).collect())
3928 .collect();
3929 if words.is_empty()
3930 && commands.is_empty()
3931 && let Some(p) = agent.preset()
3932 {
3933 commands.push(p.login.split_whitespace().map(str::to_owned).collect());
3934 }
3935 commands.dedup();
3936 let argvs: Vec<speccoder_core::step::Argvs> = commands.into_iter().map(|c| vec![c]).collect();
3937 let mut fix: Vec<String> = argvs
3938 .iter()
3939 .map(|a| speccoder_core::step::render(a))
3940 .collect();
3941 fix.extend(words);
3942 fix.push("speccoder doctor".to_owned());
3943 fail(
3944 CommandStatus::NeedsHuman,
3945 format!("{shown} needs signing in before it can make the attempt; nothing was attempted"),
3946 )
3947 .with_code("EXECUTOR-LOGIN")
3948 .with_fix(fix)
3949 .with_commands(argvs)
3950}
3951
3952fn story_red(report: &speccoder_core::RunReport) -> Option<speccoder_core::verify::RedClass> {
3955 let a = report
3956 .acceptance
3957 .as_ref()
3958 .filter(|a| a["oracle"].is_string())?;
3959 serde_json::from_value::<speccoder_core::verify::RedClass>(a["red_class"].clone())
3960 .ok()
3961 .filter(|c| c.is_red())
3962}
3963
3964fn with_oracle(bundle: &serde_json::Value, oracle: &serde_json::Value) -> serde_json::Value {
3967 let mut b = bundle.clone();
3968 if !oracle.is_null() {
3969 b["oracle"] = oracle.clone();
3970 }
3971 b
3972}