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 let ruled: Vec<crate::acceptance_at::Ruling> = if bug_fix.is_none() && !in_story {
1314 crate::acceptance_at::rulings_for_task(&s.plane_dir, &feature_name, task)
1315 .unwrap_or_default()
1316 } else {
1317 Vec::new()
1318 };
1319 let told: Vec<String> = ruled
1320 .iter()
1321 .map(|r| format!("{} ruled the tests wrong: {}", r.by, r.reason))
1322 .collect();
1323 if bug_fix.is_none()
1324 && run_config.acceptance
1325 && !options.fresh_acceptance
1326 && planning.is_none()
1327 && !in_story
1328 && ruled.is_empty()
1329 {
1330 match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
1331 Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
1332 Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
1333 "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
1334 )),
1335 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
1336 notices.push(format!("{req}: frozen oracle not present here — authoring"))
1337 }
1338 Some(crate::oracle::TaskReuse::Lapsed {
1339 req,
1340 frozen,
1341 current,
1342 }) => {
1343 if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
1344 live.event(&progress::Event::Oracle {
1345 step: progress::OracleEvent::Lapsed {
1346 unit: feature_name.clone(),
1347 requirement: req.clone(),
1348 frozen_wording_sha256: frozen,
1349 current_wording_sha256: current,
1350 },
1351 });
1352 }
1353 notices.push(format!(
1354 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
1355 ));
1356 }
1357 Some(crate::oracle::TaskReuse::Tampered(why)) => {
1358 return fail(
1359 CommandStatus::NeedsHuman,
1360 format!("{why}: the task stops for a person, nothing is ticked"),
1361 )
1362 .with_code("ORACLE-TAMPERED");
1363 }
1364 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
1365 return fail(
1366 CommandStatus::NeedsHuman,
1367 format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
1368 )
1369 .with_code("REVIEW-UNREADABLE");
1370 }
1371 None => {}
1372 }
1373 }
1374 let mut acceptance = if let Some(prepared) = interrupted
1375 .as_ref()
1376 .and_then(|m| m.acceptance.clone())
1377 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
1378 {
1379 let mut prepared = prepared;
1383 if let Some(sealed) = bug_fix
1384 .as_ref()
1385 .and_then(|f| f.record.held_out_sealed.as_ref())
1386 {
1387 prepared.held_out = Some(sealed.manifest_sha256.clone());
1388 prepared.report["held_out"] = serde_json::json!({
1389 "state": "on",
1390 "manifest": format!("sha256:{}", sealed.manifest_sha256),
1391 "kept": sealed.count,
1392 });
1393 }
1394 Some(prepared)
1395 } else if let Some(f) = &bug_fix {
1396 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1397 match super::bug::reuse(&bug_ctx, f) {
1398 Ok(a) => Some(a),
1399 Err(e) => {
1400 let _ = worktree::restore(&wt_root, &tmp, &start);
1401 stage(
1402 &*live,
1403 task,
1404 Stage::Red,
1405 StageState::Failed,
1406 Some(e.clone()),
1407 Some(began),
1408 );
1409 return fail(CommandStatus::NeedsHuman, e);
1410 }
1411 }
1412 } else if let Some(reuse) = review_reuse.take() {
1413 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1416 let crate::oracle::TaskReuse::Ready {
1417 record,
1418 files,
1419 tests,
1420 manifest,
1421 } = reuse
1422 else {
1423 return fail(CommandStatus::Error, "no frozen oracle".to_owned());
1424 };
1425 match reuse_pre_frozen(
1426 &project,
1427 &record,
1428 &files,
1429 &tests,
1430 &runner,
1431 RedRule::RunTime,
1432 Some(&SealedCheck {
1433 state: &s.plane_dir,
1434 unit: &feature_name,
1435 wt_root: &wt_root,
1436 tmp: &tmp,
1437 policy: &policy,
1438 }),
1439 ) {
1440 Ok(mut a) => {
1441 a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
1442 notices.push(format!(
1443 "acceptance tests frozen at review by {} ({}) for {}: no author call",
1444 record.decided.by, record.decided.via, record.requirement
1445 ));
1446 review_frozen = Some((*record, manifest));
1447 Some(a)
1448 }
1449 Err(e) => {
1450 let _ = worktree::restore(&wt_root, &tmp, &start);
1451 stage(
1452 &*live,
1453 task,
1454 Stage::Red,
1455 StageState::Failed,
1456 Some(e.clone()),
1457 Some(began),
1458 );
1459 return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
1460 }
1461 }
1462 } else if run_config.acceptance
1463 && planning.is_none()
1464 && !in_story
1465 && format.acceptance_testable(&bundle)
1466 {
1467 stage(&*live, task, Stage::Red, StageState::Started, None, None);
1468 Some(prepare_acceptance(&AcceptanceSetup {
1469 rt: &rt,
1470 plane: &plane,
1471 project: &project,
1472 worktree: &wt_root,
1473 tmp: &tmp,
1474 policy: &policy,
1475 feature: &feature_name,
1476 task,
1477 bundle: bundle_value.clone(),
1478 scenarios: &scenarios,
1479 instructions: &instructions,
1480 runner: &runner,
1481 layout: Some(&test_layout),
1482 told: &told,
1483 }))
1484 } else {
1485 None
1486 };
1487 if review_frozen.is_none()
1488 && acceptance
1489 .as_ref()
1490 .is_some_and(|a| a.report["source"] == "review")
1491 && let Some(crate::oracle::TaskReuse::Ready {
1492 record, manifest, ..
1493 }) = review_reuse.take()
1494 {
1495 review_frozen = Some((*record, manifest));
1496 }
1497 let held_out_store = match (&bug_fix, &review_frozen) {
1500 (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1501 (None, Some((r, _))) => {
1502 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1503 }
1504 (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1505 };
1506 if let Some(a) = acceptance
1507 .as_mut()
1508 .filter(|a| a.report.get("held_out").is_none())
1509 {
1510 if !run_config.held_out {
1511 a.report["held_out"] = serde_json::json!({ "state": "off" });
1512 } else if a.argv.is_none() {
1513 a.report["held_out"] =
1514 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1515 } else if !a.red {
1516 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1517 } else if scenarios.is_empty() {
1518 a.report["held_out"] =
1519 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1520 } else {
1521 let (held, sha) = prepare_held_out(
1522 &AcceptanceSetup {
1523 rt: &rt,
1524 plane: &plane,
1525 project: &project,
1526 worktree: &wt_root,
1527 tmp: &tmp,
1528 policy: &policy,
1529 feature: &feature_name,
1530 task,
1531 bundle: bundle_value.clone(),
1532 scenarios: &scenarios,
1533 instructions: &instructions,
1534 runner: &runner,
1535 layout: Some(&test_layout),
1536 told: &told,
1537 },
1538 a,
1539 &scenarios,
1540 &held_out_store,
1541 false,
1542 );
1543 a.report["held_out"] = held;
1544 a.held_out = sha;
1545 }
1546 }
1547 if run_config.held_out && !run_config.acceptance {
1548 notices.push(
1549 "[acceptance] held_out has no effect while generate = false: no held-out tests"
1550 .to_owned(),
1551 );
1552 }
1553 match &acceptance {
1554 None => stage(
1555 &*live,
1556 task,
1557 Stage::Red,
1558 StageState::Skipped,
1559 Some(if planning.is_some() {
1560 "planning work: design documents".to_owned()
1561 } else if in_story {
1562 "judged by its story's oracle".to_owned()
1563 } else {
1564 "no acceptance examples".to_owned()
1565 }),
1566 None,
1567 ),
1568 Some(a) if a.red => stage(
1569 &*live,
1570 task,
1571 Stage::Red,
1572 StageState::Passed,
1573 a.red_class.map(class_word),
1574 Some(began),
1575 ),
1576 Some(a) => stage(
1577 &*live,
1578 task,
1579 Stage::Red,
1580 StageState::Failed,
1581 Some(a.red_class.map_or_else(
1582 || "not demonstrated".to_owned(),
1583 |c| format!("not demonstrated ({})", class_word(c)),
1584 )),
1585 Some(began),
1586 ),
1587 }
1588
1589 if let Some(why) = acceptance
1593 .as_ref()
1594 .and_then(|a| a.generation_failed.clone())
1595 {
1596 let reason = format!(
1597 "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"
1598 );
1599 let _ = worktree::restore(&wt_root, &tmp, &start);
1600 let report = RunReport {
1601 reason: Some(reason.clone()),
1602 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1603 set_aside,
1604 notices: notices.clone(),
1605 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1606 };
1607 live.event(&finished_event(
1608 task,
1609 &report,
1610 "acceptance_failed",
1611 false,
1612 redact(&reason, secret),
1613 ));
1614 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1615 .with_code("ACCEPTANCE-UNWRITTEN")
1616 .with_data(CommandData::Run(Box::new(report)));
1617 }
1618
1619 if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
1620 let why = undemonstrated_why(acceptance.as_ref());
1621 let reason = format!(
1622 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1623 );
1624 let saved = attempts::save(
1626 &wt_root,
1627 &tmp,
1628 &feature_name,
1629 task,
1630 &attempts::Record {
1631 run: None,
1632 outcome: "not_demonstrated",
1633 reason: &reason,
1634 pending: None,
1635 usage: None,
1636 story_open: &[],
1637 own_test: None,
1638 },
1639 )
1640 .ok()
1641 .map(|s| s.reference);
1642 let _ = worktree::restore(&wt_root, &tmp, &start);
1643 let report = RunReport {
1644 reason: Some(reason.clone()),
1645 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1646 saved_attempt: saved,
1647 set_aside,
1648 notices: notices.clone(),
1649 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1650 };
1651 live.event(&finished_event(
1652 task,
1653 &report,
1654 "not_demonstrated",
1655 false,
1656 redact(&reason, secret),
1657 ));
1658 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1659 .with_data(CommandData::Run(Box::new(report)));
1660 }
1661
1662 let before = match &interrupted {
1665 Some(m) => worktree::Checkpoint(m.before.clone()),
1666 None => match worktree::checkpoint(&wt_root, &tmp) {
1667 Ok(c) => c,
1668 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1669 },
1670 };
1671 let acceptance_saved = acceptance
1672 .as_ref()
1673 .and_then(|a| serde_json::to_value(a).ok());
1674 let frozen: Vec<String> = match worktree::files(&wt_root, &before) {
1676 Ok(f) => f,
1677 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1678 }
1679 .into_iter()
1680 .filter(|p| tools::paths::is_test_path(p))
1681 .collect();
1682 let mut usage: Option<serde_json::Value> = None;
1683 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1686 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1688 let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1690 let mut refused_account = false;
1692 let (mut report, attempt_status) = loop {
1693 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1694 let mut digest: Option<serde_json::Value> = None;
1695 let mut attempt_no = interrupted
1698 .as_ref()
1699 .filter(|m| m.sample == samples.len())
1700 .map_or(1, |m| m.attempt.max(1));
1701 let (mut report, attempt_status) = loop {
1702 let hidden: Vec<String> = options
1704 .session
1705 .as_ref()
1706 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1707 .unwrap_or_default();
1708 let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1714 Ok(_) if autopilot => (Vec::new(), Vec::new()),
1717 Ok(mut d) => {
1718 let once = d.take_once(&feature_name, task, this_run.as_str());
1719 if !once.is_empty()
1720 && let Err(e) = d.save(&s.plane_dir)
1721 {
1722 return fail(CommandStatus::Error, e);
1723 }
1724 (d.for_run(&feature_name, task, this_run.as_str()), once)
1725 }
1726 Err(e) => return fail(CommandStatus::ConfigError, e),
1727 };
1728 let run_approved: Vec<crate::tools::policy::Object> = options
1730 .run_grants
1731 .lock()
1732 .map(|g| g.clone())
1733 .unwrap_or_default();
1734 let input = substrate::attempt::input(
1736 with_oracle(&bundle_value, &oracle_note),
1737 &substrate::attempt::Settings {
1738 checks: &check_argvs,
1739 allow: &run_config.allow,
1740 allow_prefix: &run_config.allow_prefix,
1741 max_turns: run_config.max_turns,
1742 max_tool_calls: run_config.max_tool_calls,
1743 reasoning: s.model.reasoning_summary,
1744 frozen: &frozen,
1745 approved: &task_grants,
1746 rejected: &decisions.rejected,
1747 run_approved: &run_approved,
1748 approved_once: &approved_once,
1749 nudges: &decisions.nudges,
1750 mcp: &connected.tools,
1751 root: &wt_root_canonical,
1752 frozen_additions: run_config.frozen_additions,
1753 asks: if options.lane.is_some() {
1756 "stop"
1757 } else {
1758 "tasks"
1759 },
1760 wait_secs: run_config.approval_wait.as_secs(),
1761 network_allow: &sandbox_config.network_allow,
1762 network_allow_prefix: &sandbox_config.network_allow_prefix,
1763 run_id: this_run.as_str(),
1764 asked: options.asked_now(),
1765 budget: run_config.approval_budget,
1766 prompts: approver.is_some() && options.lane.is_none(),
1767 in_place: options.session.is_some(),
1768 hidden: &hidden,
1769 autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1770 planning: planning.as_deref(),
1771 },
1772 digest.as_ref(),
1773 attempt_instructions,
1774 (!test_layout.runners.is_empty()).then_some(test_layout_text.as_str()),
1776 );
1777 let attempt_began = std::time::Instant::now();
1778 let attempt_detail = if run_config.best_of > 1 {
1779 format!(
1780 "{attempt_no}/{} · sample {}/{}",
1781 run_config.max_attempts,
1782 samples.len() + 1,
1783 run_config.best_of
1784 )
1785 } else {
1786 format!("{attempt_no}/{}", run_config.max_attempts)
1787 };
1788 stage(
1789 &*live,
1790 task,
1791 Stage::Attempt,
1792 StageState::Started,
1793 Some(attempt_detail.clone()),
1794 None,
1795 );
1796 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1799 let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1802 let held: Vec<String> = bug_fix
1803 .as_ref()
1804 .and_then(|f| {
1805 f.bug
1806 .held_out_sealed
1807 .clone()
1808 .map(|h| (f.bug.unit.clone(), h))
1809 })
1810 .and_then(|(unit, h)| {
1811 crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1812 })
1813 .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1814 .unwrap_or_default();
1815 match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1816 Ok(x) => x,
1817 Err(stop) => return *stop,
1818 }
1819 } else if let Some(exec) = &executor {
1820 let allow: Vec<Vec<String>> = check_argvs
1821 .iter()
1822 .chain(&run_config.allow)
1823 .cloned()
1824 .collect();
1825 let prompt = external::prompt_in(
1826 format.wording(),
1827 &with_oracle(&bundle_value, &oracle_note),
1828 digest.as_ref(),
1829 );
1830 let withheld = acceptance
1831 .as_ref()
1832 .and_then(|a| a.held_out.as_ref())
1833 .map(|_| held_out_store.as_path());
1834 if withheld.is_some()
1835 && !notices
1836 .iter()
1837 .any(|n| n.starts_with("held-out set withheld"))
1838 {
1839 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());
1840 }
1841 let _ = allow;
1842 let o = match &exec.kind {
1843 external::Kind::Command(argv) => {
1844 external::run(argv, exec.timeout, &wt_root, &prompt, &tmp)
1845 }
1846 external::Kind::Acp(agent) => {
1847 let policy = match substrate::attempt::policy_of(
1850 &input.peek()["settings"],
1851 format.rules(),
1852 ) {
1853 Ok(p) => p,
1854 Err(e) => return fail(CommandStatus::RunFailed, e.to_string()),
1855 };
1856 let tools = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
1857 Ok(t) => t.with_runner(runner.clone()),
1858 Err(e) => {
1859 return fail(
1860 CommandStatus::Error,
1861 format!("{}: {e}", wt_root.display()),
1862 );
1863 }
1864 };
1865 let governor = Arc::new(crate::acp::govern::Governor::new(
1866 crate::acp::govern::Setup {
1867 root: std::fs::canonicalize(&wt_root)
1868 .unwrap_or_else(|_| wt_root.clone()),
1869 tools,
1870 policy,
1871 approver: approver.clone().filter(|_| options.lane.is_none()),
1873 present: present.clone(),
1874 task: format!("{feature_name}/{task}"),
1875 task_title: parsed_task
1876 .as_ref()
1877 .map(|t| t.title.clone())
1878 .unwrap_or_default(),
1879 run: Some(this_run.to_string()),
1880 frozen_additions: run_config.frozen_additions,
1881 run_approved: run_approved.clone(),
1882 approved_once: approved_once.clone(),
1883 progress: Some((task.to_owned(), Arc::clone(&live))),
1884 copied: copied_files.clone(),
1885 agent: agent.id.clone(),
1886 },
1887 ));
1888 let a = external::run_acp(
1889 exec,
1890 agent,
1891 &wt_root,
1892 &prompt,
1893 &runner,
1894 governor,
1895 Some((task.to_owned(), Arc::clone(&live))),
1896 );
1897 if let Some(ways) = &a.sign_in {
1898 return login(fail, &a.agent, agent, ways);
1899 }
1900 for n in a.outcome.output["acp"]["notices"]
1901 .as_array()
1902 .into_iter()
1903 .flatten()
1904 .filter_map(serde_json::Value::as_str)
1905 {
1906 notices.push(format!("{}: {n}", a.agent));
1907 }
1908 a.outcome
1909 }
1910 };
1911 (o.run_id, true, o.output)
1912 } else {
1913 let key = match &interrupted {
1915 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
1916 m.key.clone()
1917 }
1918 _ => resume::new_key(&feature_name, task),
1919 };
1920 let _ = resume::save(
1921 &marker_path,
1922 &resume::Marker {
1923 key: key.clone(),
1924 before: before.0.clone(),
1925 head: head_now.clone(),
1926 attempt: attempt_no,
1927 sample: samples.len(),
1928 acceptance: acceptance_saved.clone(),
1929 story_before: story_oracle
1930 .as_ref()
1931 .and_then(|_| serde_json::to_value(&before_run).ok()),
1932 run_id: Some(this_run.to_string()),
1933 asked: options.asked_now(),
1934 run_grants: run_approved.clone(),
1935 },
1936 );
1937 marker_guard.arm();
1938 let outcome = match rt.block_on(plane.attempt_keyed(
1939 &feature_name,
1940 task,
1941 input,
1942 &key,
1943 )) {
1944 Ok((o, resumed)) => {
1945 if resumed {
1946 notices.push(format!(
1947 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
1948 o.run_id
1949 ));
1950 }
1951 o
1952 }
1953 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1954 };
1955 let context = crate::approvals::PresentContext {
1959 root: Some(wt_root.clone()),
1960 ..present.clone()
1961 };
1962 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
1963 outcome,
1964 approver.as_deref(),
1965 &context,
1966 )) {
1967 Ok(x) => {
1968 notices.extend(crate::actions::allow_always_inline(
1971 path,
1972 &feature_name,
1973 &x.always,
1974 state,
1975 ));
1976 (x.outcome, x.waiting)
1977 }
1978 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1979 };
1980 if let Some(task_row) = waiting {
1981 marker_guard.keep();
1984 let action = &task_row.justification.proposed_action;
1985 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
1986 report.judged.clone_from(&judged_by);
1987 report.run_id = outcome.run_id.to_string();
1988 report.notices = notices;
1989 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
1990 report.tool_calls.push(serde_json::json!({
1992 "tool": action["tool"],
1993 "arguments": action["arguments"],
1994 "decision": "approval",
1995 "reason": action["request"]["reason"],
1996 "object": action["request"]["object"],
1997 "waiting": true,
1998 "call": task_row.id.to_hex(),
1999 }));
2000 report.pending = Some(serde_json::json!({
2001 "tool": action["tool"],
2002 "arguments": action["arguments"],
2003 "reason": action["request"]["reason"],
2004 "object": action["request"]["object"],
2005 "request": action["request"],
2006 "call": task_row.id.to_hex(),
2007 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
2008 "waiting": true,
2009 "batched": action["request"]["batched"],
2010 }));
2011 if let (Some(n), Ok(mut asked)) = (
2014 action["request"]["asked"]
2015 .as_u64()
2016 .and_then(|n| u32::try_from(n).ok()),
2017 options.asked.lock(),
2018 ) {
2019 *asked = (*asked).max(n);
2020 }
2021 report.reason = Some(format!(
2022 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
2023 task_row.justification.summary.peek()
2024 ));
2025 let (status, message) = status_and_message(&report, "needs_approval", false);
2026 live.event(&finished_event(
2027 task,
2028 &report,
2029 "needs_approval",
2030 false,
2031 redact(&message, secret),
2032 ));
2033 return CommandResult::new("run", status, &redact(&message, secret))
2034 .with_data(CommandData::Run(Box::new(report)));
2035 }
2036 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
2037 refused_account = outcome
2038 .failed_effect
2039 .as_ref()
2040 .is_some_and(agentplane::journal::FailedEffect::refuses_the_account);
2041 let mut output = outcome
2042 .output
2043 .as_ref()
2044 .map(|o| o.peek().clone())
2045 .unwrap_or(serde_json::Value::Null);
2046 if !succeeded && output["reason"].as_str().is_none() {
2047 if !output.is_object() {
2048 output = serde_json::json!({});
2049 }
2050 let reason = if rt.block_on(plane.holds_a_pre_054_decision(outcome.run_id)) {
2053 substrate::PRE_054_DECISION.to_owned()
2054 } else if let agentplane::prelude::RunStatus::Exhausted(limit) = &outcome.status
2055 {
2056 output["status"] = serde_json::json!("budget_exhausted");
2058 substrate::exhausted(limit)
2059 } else if let agentplane::prelude::RunStatus::Quarantined(why) = &outcome.status
2060 {
2061 output["status"] = serde_json::json!("quarantined");
2062 why.clone()
2063 } else {
2064 substrate::why_not(&outcome.status)
2065 };
2066 output["reason"] = serde_json::Value::String(redact(&reason, secret));
2067 }
2068 if output.get("usage").is_none_or(serde_json::Value::is_null)
2071 && let Ok(Some(used)) =
2072 rt.block_on(substrate::spent(&s.plane_dir, &outcome.run_id.to_string()))
2073 {
2074 output["usage"] = used;
2075 }
2076 (outcome.run_id.to_string(), succeeded, output)
2077 };
2078 if bug_candidate.is_some()
2079 && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
2080 {
2081 c.applied = true;
2082 }
2083 if let Some(argv) = run_config.format.as_ref().filter(|_| {
2086 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
2087 }) {
2088 use verify::process::CommandRunner as _;
2089 let ran = runner.run(&verify::process::Invocation {
2090 argv: argv.clone(),
2091 cwd: project.clone(),
2092 env: verify::process::scrubbed_env(std::env::vars()),
2093 timeout: std::time::Duration::from_secs(300),
2094 output_cap: verify::process::OUTPUT_CAP,
2095 });
2096 if ran.exit_code != Some(0) {
2097 notices.push(format!(
2098 "the formatter (`{}`) did not succeed",
2099 argv.join(" ")
2100 ));
2101 }
2102 }
2103 if prepares {
2107 let began = std::time::Instant::now();
2108 match crate::prepare::recheck(&crate::prepare::Prepare {
2109 worktree: &wt_root,
2110 project: &project,
2111 config: &worktree_config,
2112 runner: &runner,
2113 state: Some(state),
2114 }) {
2115 Ok(done) if done.is_empty() => {}
2116 Ok(done) => {
2117 stage(
2118 &*live,
2119 task,
2120 Stage::Prepare,
2121 StageState::Passed,
2122 Some(format!(
2123 "{}: the attempt changed its lockfile",
2124 done.join(", ")
2125 )),
2126 Some(began),
2127 );
2128 }
2129 Err(f) => {
2130 stage(
2131 &*live,
2132 task,
2133 Stage::Prepare,
2134 StageState::Failed,
2135 Some(f.message.clone()),
2136 Some(began),
2137 );
2138 let reason = redact(&f.message, secret);
2142 let saved = attempts::save(
2143 &wt_root,
2144 &tmp,
2145 &feature_name,
2146 task,
2147 &attempts::Record {
2148 run: (!run_id.is_empty()).then_some(run_id.as_str()),
2149 outcome: "prepare_failed",
2150 reason: &reason,
2151 pending: None,
2152 usage: output.get("usage"),
2153 story_open: &[],
2154 own_test: None,
2155 },
2156 )
2157 .and_then(|saved| {
2158 worktree::restore(&wt_root, &tmp, &before).map(|_| saved)
2159 });
2160 let message = match saved {
2161 Ok(saved) => format!(
2162 "{} — the attempt is saved as {} and undone",
2163 f.message, saved.reference
2164 ),
2165 Err(e) => format!("{} — saving the attempt failed: {}", f.message, e.0),
2166 };
2167 return fail(CommandStatus::ConfigError, message)
2168 .with_code("PREPARE-FAILED")
2169 .with_fix(f.fix)
2170 .with_commands(f.commands);
2171 }
2172 }
2173 }
2174 let after = match worktree::checkpoint(&wt_root, &tmp) {
2175 Ok(c) => c,
2176 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2177 };
2178 let raw_changes = match worktree::diff(&wt_root, &before, &after) {
2180 Ok(c) => c,
2181 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2182 };
2183 if let (Some(n), Ok(mut asked)) = (
2188 output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
2189 options.asked.lock(),
2190 ) {
2191 *asked = n;
2192 }
2193 let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
2194 let patch = match worktree::patch(&wt_root, &before, &after) {
2195 Ok(p) => p,
2196 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2197 };
2198 if let Some(name) = crate::include::added_secret(&patch) {
2201 let _ = worktree::restore(&wt_root, &tmp, &before);
2202 return fail(
2203 CommandStatus::NeedsHuman,
2204 format!(
2205 "the attempt's change adds the value of {name}, copied from your checkout ([worktree] include): undone, nothing judged or committed"
2206 ),
2207 )
2208 .with_code("SECRET-IN-DIFF");
2209 }
2210 let findings = attempt_findings(&Audited {
2211 wt_root: &wt_root,
2212 policy: &policy,
2213 judged_before: &judged_before,
2214 raw_changes: &raw_changes,
2215 patch: &patch,
2216 frozen: &frozen,
2217 frozen_additions: run_config.frozen_additions,
2218 approved_writes: &approved_writes,
2219 command_drift: &output["integrity"],
2220 });
2221 let changes = changes_of(raw_changes);
2222
2223 let quarantined = !succeeded && output["status"] == "quarantined";
2228 if quarantined {
2229 output["reason"] = serde_json::Value::String(
2230 "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(),
2231 );
2232 }
2233 let mut attempt_status = if succeeded {
2234 output["status"].as_str().unwrap_or("failed").to_owned()
2235 } else if quarantined {
2236 "interrupted".to_owned()
2237 } else if output["status"] == "budget_exhausted" {
2238 "budget_exhausted".to_owned()
2239 } else {
2240 "failed".to_owned()
2241 };
2242 if attempt_status == "completed" && changes.is_empty() && output.get("acp").is_some() {
2246 attempt_status = "blocked".to_owned();
2247 output["reason"] = serde_json::json!(format!(
2248 "{} ended its turn without changing anything",
2249 output["acp"]["agent"].as_str().unwrap_or("the agent")
2250 ));
2251 }
2252 let mut report = RunReport {
2253 run_id: run_id.clone(),
2254 model_report: output.get("report").cloned(),
2255 reason: output["reason"].as_str().map(str::to_owned),
2256 pending: output.get("pending").cloned(),
2257 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
2258 changes,
2259 usage: output.get("usage").cloned(),
2260 integrity: findings.clone(),
2261 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
2262 set_aside: set_aside.clone(),
2263 notices: notices.clone(),
2264 agent: external::agent_attempt(&output),
2265 judged: judged_by.clone(),
2266 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
2267 };
2268 if let Err(e) = record_inline_approvals(
2269 &s.plane_dir,
2270 &feature_name,
2271 task,
2272 &report.tool_calls,
2273 &options.run_grants,
2274 this_run.as_str(),
2275 ) {
2276 report.notices.push(format!(
2277 "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
2278 ));
2279 }
2280 for c in report
2282 .tool_calls
2283 .iter()
2284 .filter(|c| c["approved_by"] == "person")
2285 {
2286 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
2287 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
2288 }
2289 }
2290
2291 stage(
2292 &*live,
2293 task,
2294 Stage::Attempt,
2295 if attempt_status == "completed" {
2296 StageState::Passed
2297 } else {
2298 StageState::Failed
2299 },
2300 Some(if attempt_status == "completed" {
2301 attempt_detail
2302 } else {
2303 format!("{attempt_detail} {attempt_status}")
2304 }),
2305 Some(attempt_began),
2306 );
2307 if attempt_status == "integrity" {
2310 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2311 reasons: findings.clone(),
2312 });
2313 }
2314 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
2316 if judged {
2317 let began = std::time::Instant::now();
2318 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
2319 let oracle_files: Vec<String> = story_oracle
2320 .iter()
2321 .flat_map(|o| o.tests.iter().map(|t| t.path.clone()))
2322 .collect();
2323 if let Err(e) = judge_checks(
2324 &project,
2325 &runner,
2326 (&baseline, &oracle_files),
2327 acceptance.as_ref(),
2328 &findings,
2329 &mut report,
2330 ) {
2331 stage(
2332 &*live,
2333 task,
2334 Stage::Verify,
2335 StageState::Failed,
2336 None,
2337 Some(began),
2338 );
2339 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
2340 }
2341 if let Some(o) = &story_oracle {
2344 let touched = super::story::changed(&project, o);
2347 if !touched.is_empty() {
2348 let why = format!(
2349 "the story's oracle tests were changed: {}",
2350 touched.join(", ")
2351 );
2352 report.verdict = Some(match report.verdict.take() {
2353 Some(speccoder_core::verify::Verdict::Failed { mut reasons }) => {
2354 reasons.push(why);
2355 speccoder_core::verify::Verdict::Failed { reasons }
2356 }
2357 _ => speccoder_core::verify::Verdict::Failed { reasons: vec![why] },
2358 });
2359 }
2360 let after = super::story::states(&project, o, &runner);
2361 super::story::judge(
2362 o,
2363 &before_run.states,
2364 &after,
2365 judged_by
2366 .as_ref()
2367 .map(speccoder_core::story::Judged::links)
2368 .unwrap_or_default(),
2369 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint)),
2370 &mut report,
2371 );
2372 if let Some(speccoder_core::story::Progress::Verified { turned }) =
2374 &report.progress
2375 {
2376 report.acceptance = Some(super::story::evidence(o, turned, &before_run));
2377 }
2378 }
2379 if let Some(dir) = planning.as_ref().filter(|_| {
2383 matches!(
2384 report.verdict,
2385 Some(
2386 speccoder_core::verify::Verdict::Verified
2387 | speccoder_core::verify::Verdict::RegressionOnly
2388 )
2389 )
2390 }) {
2391 let inside = format!("{dir}/");
2392 let outside: Vec<&str> = report
2393 .changes
2394 .iter()
2395 .filter(|(p, _)| !p.starts_with(&inside))
2396 .map(|(p, _)| p.as_str())
2397 .collect();
2398 let reason = if !outside.is_empty() {
2399 Some(format!(
2400 "a planning task changed code: {}",
2401 outside.join(", ")
2402 ))
2403 } else if !report.changes.iter().any(|(_, kind)| kind != "deleted") {
2404 Some(format!("wrote no planning file under {inside}"))
2405 } else {
2406 None
2407 };
2408 if !outside.is_empty() {
2409 attempt_status = CHANGED_CODE.to_owned();
2411 report.attempt_status = CHANGED_CODE.to_owned();
2412 }
2413 if let Some(r) = reason {
2414 report.verdict =
2415 Some(speccoder_core::verify::Verdict::Failed { reasons: vec![r] });
2416 }
2417 }
2418 let otherwise_passing = attempt_status == "completed"
2422 && findings.is_empty()
2423 && matches!(
2424 report.verdict,
2425 Some(
2426 speccoder_core::verify::Verdict::Verified
2427 | speccoder_core::verify::Verdict::RegressionOnly
2428 )
2429 );
2430 match judge_collection(
2431 (&project, &wt_root, &tmp),
2432 &runner,
2433 otherwise_passing,
2434 &mut report,
2435 &*live,
2436 task,
2437 ) {
2438 Some(Collected::Unknown(why)) => {
2439 attempt_status = "collection_unknown".to_owned();
2440 report.verdict = None;
2441 report.reason = Some(why);
2442 }
2443 Some(Collected::Changed(finding)) => {
2445 report.integrity.push(finding.clone());
2446 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2447 reasons: vec![finding],
2448 });
2449 attempt_status = "integrity".to_owned();
2450 }
2451 None => {}
2452 }
2453 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
2454 stage_with_tail(
2455 &*live,
2456 task,
2457 Stage::Verify,
2458 if checks_held {
2459 StageState::Passed
2460 } else {
2461 StageState::Failed
2462 },
2463 None,
2464 Some(began),
2465 report
2466 .verification
2467 .as_ref()
2468 .map(failing_tail)
2469 .unwrap_or_default(),
2470 );
2471 }
2472 if judged || attempt_status == "integrity" {
2473 stage(
2474 &*live,
2475 task,
2476 Stage::Audit,
2477 if findings.is_empty() {
2478 StageState::Passed
2479 } else {
2480 StageState::Failed
2481 },
2482 findings
2483 .first()
2484 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
2485 None,
2486 );
2487 }
2488
2489 let visibly_passed = judged
2493 && findings.is_empty()
2494 && attempt_status == "completed"
2495 && matches!(
2496 report.verdict,
2497 Some(
2498 speccoder_core::verify::Verdict::Verified
2499 | speccoder_core::verify::Verdict::RegressionOnly
2500 )
2501 );
2502 let held = acceptance
2505 .as_ref()
2506 .and_then(|a| a.held_out.clone())
2507 .map(|sha| (held_out_store.clone(), sha))
2508 .or_else(|| {
2509 story_oracle
2510 .as_ref()
2511 .filter(|_| {
2512 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
2513 })
2514 .and_then(|o| o.held_out.as_ref())
2515 .map(|h| {
2516 (
2517 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &h.key),
2518 h.manifest_sha256.clone(),
2519 )
2520 })
2521 });
2522 let at_checkpoint =
2525 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint));
2526 if at_checkpoint
2527 && visibly_passed
2528 && held.is_none()
2529 && let Some(summary) = story_oracle
2530 .as_ref()
2531 .and_then(|o| o.held_out_summary.as_ref())
2532 {
2533 let entry = report
2534 .acceptance
2535 .get_or_insert_with(|| serde_json::json!({ "source": "authored" }));
2536 entry["held_out"] = match summary.state.as_str() {
2537 "off" => serde_json::json!({ "state": "off" }),
2538 _ => serde_json::json!({
2539 "state": "absent",
2540 "reason": summary.reason,
2541 "discarded": summary
2542 .discarded
2543 .iter()
2544 .map(|r| serde_json::json!({ "reason": r }))
2545 .collect::<Vec<_>>(),
2546 }),
2547 };
2548 }
2549 if acceptance
2550 .as_ref()
2551 .and_then(|a| a.held_out.as_ref())
2552 .is_none()
2553 && let (Some((_, sha)), Some(o)) = (&held, &story_oracle)
2554 && visibly_passed
2555 {
2556 let entry = report.acceptance.get_or_insert_with(
2557 || serde_json::json!({ "source": "authored", "oracle": o.commit }),
2558 );
2559 entry["held_out"] = serde_json::json!({
2560 "state": "on",
2561 "manifest": format!("sha256:{sha}"),
2562 "kept": o.held_out.as_ref().map_or(0, |h| h.count),
2563 });
2564 }
2565 if let Some((store, sha)) = held.filter(|_| visibly_passed) {
2566 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2567 let mut known = format!(
2568 "{}\n{}\n{}",
2569 bundle_value["story"].as_str().unwrap_or_default(),
2570 bundle_value["plan"].as_str().unwrap_or_default(),
2571 bundle_value["constitution"].as_str().unwrap_or_default()
2572 );
2573 for p in report
2574 .acceptance
2575 .as_ref()
2576 .and_then(|a| a["files"].as_array().cloned())
2577 .unwrap_or_default()
2578 {
2579 if let Some(p) = p.as_str() {
2580 known.push_str(
2581 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2582 );
2583 }
2584 }
2585 match judge_held_out(
2586 &store, &sha, &scenarios, &known, &wt_root, &project, &tmp, &before, &patch,
2587 &runner, &policy,
2588 ) {
2589 Ok(j) => {
2590 let failed_ids: Vec<String> =
2591 j.failed.iter().map(|s| s.id.clone()).collect();
2592 held_out_log.push(serde_json::json!({
2593 "attempt": held_out_log.len() + 1,
2594 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
2595 "scenarios": failed_ids,
2596 }));
2597 if let Some(v) = report.verdict.take() {
2598 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
2599 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
2600 && !j.failed.is_empty()
2601 && let Some(last) = reasons.last_mut()
2602 {
2603 *last = speccoder_core::retry::held_out_reason(&j.failed);
2604 }
2605 report.verdict = Some(v);
2606 }
2607 }
2608 Err((outcome, why)) => {
2609 held_out_log.push(serde_json::json!({
2610 "attempt": held_out_log.len() + 1,
2611 "outcome": outcome,
2612 }));
2613 report.verdict = None;
2614 report.reason = Some(why);
2615 attempt_status = "held_out".to_owned();
2616 }
2617 }
2618 }
2619 if let Some(fix) = bug_fix.as_mut().filter(|_| {
2622 attempt_status == "completed"
2623 && findings.is_empty()
2624 && matches!(
2625 report.verdict,
2626 Some(
2627 speccoder_core::verify::Verdict::Verified
2628 | speccoder_core::verify::Verdict::RegressionOnly
2629 )
2630 )
2631 }) {
2632 let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
2633 let judging = super::bug::Judging {
2634 before: &before,
2635 baseline: &baseline,
2636 verification: report.verification.as_ref(),
2637 base: report
2638 .verdict
2639 .clone()
2640 .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
2641 keep: &keep,
2642 };
2643 match super::bug::judge(&bug_ctx, fix, &judging) {
2644 Ok(j) => {
2645 report.verdict = Some(j.verdict);
2646 if let Some(weak) = j.weak {
2647 report.reason = Some(format!(
2648 "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
2649 weak.scenario, weak.rationale
2650 ));
2651 bug_weak = Some(weak);
2652 attempt_status = "repro_weak".to_owned();
2653 }
2654 }
2655 Err(e) => {
2656 report.integrity.push(e.clone());
2657 report.reason = Some(e);
2658 report.verdict = None;
2659 attempt_status = "integrity".to_owned();
2660 }
2661 }
2662 }
2663 if let Some(h) = report
2664 .acceptance
2665 .as_mut()
2666 .and_then(|a| a.get_mut("held_out"))
2667 .filter(|h| h["state"] == "on")
2668 {
2669 let failed = held_out_log
2670 .iter()
2671 .filter(|o| o["outcome"] == "failed")
2672 .count();
2673 h["attempts"] = serde_json::json!(held_out_log);
2674 h["gap"] = serde_json::json!(failed > 0);
2675 }
2676
2677 let passing = attempt_status == "completed"
2679 && matches!(
2680 report.verdict,
2681 Some(
2682 speccoder_core::verify::Verdict::Verified
2683 | speccoder_core::verify::Verdict::RegressionOnly
2684 )
2685 );
2686 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
2687 stage(
2688 &*live,
2689 task,
2690 Stage::Mutation,
2691 StageState::Skipped,
2692 Some(if mutation_config.is_none() {
2693 "not configured".to_owned()
2694 } else {
2695 "suite = false: for acceptance strength only".to_owned()
2696 }),
2697 None,
2698 );
2699 }
2700 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
2701 let began = std::time::Instant::now();
2702 stage(
2703 &*live,
2704 task,
2705 Stage::Mutation,
2706 StageState::Started,
2707 None,
2708 None,
2709 );
2710 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
2711 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
2712 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
2713 if m.mode == mutation::Mode::Require
2714 && let Some(reason) = mutation::failure(&result)
2715 {
2716 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
2717 reasons: vec![reason],
2718 });
2719 }
2720 report.mutation = Some(result);
2721 }
2722 let held = !matches!(
2723 report.verdict,
2724 Some(speccoder_core::verify::Verdict::Failed { .. })
2725 );
2726 stage(
2727 &*live,
2728 task,
2729 Stage::Mutation,
2730 if held {
2731 StageState::Passed
2732 } else {
2733 StageState::Failed
2734 },
2735 report
2736 .mutation
2737 .as_ref()
2738 .and_then(crate::mutation::trailer)
2739 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
2740 Some(began),
2741 );
2742 }
2743
2744 usage = add_usage(usage.as_ref(), report.usage.as_ref());
2745 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
2747 (report.verdict.clone(), report.verification.as_ref())
2748 else {
2749 break (report, attempt_status);
2750 };
2751 if attempt_status != "completed" {
2752 break (report, attempt_status);
2753 }
2754 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
2755 let mut d =
2756 speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
2757 d.uncollected = report
2758 .collection
2759 .as_ref()
2760 .map(speccoder_core::collection::Collection::retry_lines)
2761 .unwrap_or_default();
2762 let fingerprint = speccoder_core::retry::fingerprint(&d);
2763 let stop = if bug_candidate.is_some() {
2764 Some("a candidate is judged, never retried".to_owned())
2765 } else {
2766 retry_stop(
2767 &findings,
2768 verification,
2769 earlier.last().map(|e| e.fingerprint.as_str()),
2770 &fingerprint,
2771 attempt_no,
2772 run_config.max_attempts,
2773 )
2774 };
2775 if let Some(stop) = stop {
2776 report.retry_stop = Some(stop);
2777 break (report, attempt_status);
2778 }
2779 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2780 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
2781 break (report, attempt_status);
2782 }
2783 earlier.push(speccoder_core::EarlierAttempt {
2784 run_id: report.run_id.clone(),
2785 attempt_status: attempt_status.clone(),
2786 verdict: report.verdict.clone(),
2787 fingerprint,
2788 digest: d.clone(),
2789 usage: report.usage.clone(),
2790 });
2791 digest = serde_json::to_value(&d).ok();
2792 attempt_no += 1;
2793 };
2794 report.earlier = earlier;
2795 if run_config.best_of <= 1 {
2796 break (report, attempt_status);
2797 }
2798 let end = match worktree::checkpoint(&wt_root, &tmp) {
2799 Ok(c) => c,
2800 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
2801 };
2802 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
2803 let summary = sample_summary(&report, &attempt_status, &patch);
2804 samples.push((report, attempt_status, end, summary));
2805 if samples.len() >= run_config.best_of as usize {
2806 let best = best_sample(&samples);
2807 let summaries: Vec<serde_json::Value> = samples
2808 .iter()
2809 .enumerate()
2810 .map(|(i, s)| {
2811 let mut v = s.3.clone();
2812 v["kept"] = serde_json::json!(i == best);
2813 v
2814 })
2815 .collect();
2816 let (mut chosen, status, end, _) = samples.swap_remove(best);
2817 if best != samples.len() {
2818 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
2820 return fail(CommandStatus::GitPrecondition, e.0);
2821 }
2822 }
2823 chosen.samples = summaries;
2824 break (chosen, status);
2825 }
2826 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
2827 return fail(CommandStatus::GitPrecondition, e.0);
2828 }
2829 };
2830 report.usage = usage;
2831 {
2834 use speccoder_core::context::ContextKind as K;
2835 let part = |k: &str| {
2836 bundle_value[k]
2837 .as_str()
2838 .unwrap_or_default()
2839 .as_bytes()
2840 .to_vec()
2841 };
2842 let mut m = crate::context::Manifest::new();
2843 m.push(
2844 K::Constitution,
2845 "worktree",
2846 "/bundle/constitution",
2847 &part("constitution"),
2848 )
2849 .push(K::Plan, "worktree", "/bundle/plan", &part("plan"))
2850 .push(K::Story, "worktree", "/bundle/story", &part("story"))
2851 .push(
2852 K::Task,
2853 "worktree",
2854 "/bundle/task",
2855 serde_json::to_string(&bundle_value["task"])
2856 .unwrap_or_default()
2857 .as_bytes(),
2858 )
2859 .push(
2860 K::TestLayout,
2861 "worktree",
2862 "/test_layout",
2863 test_layout_text.as_bytes(),
2864 );
2865 report.context = Some(m.finish());
2866 }
2867 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
2869
2870 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
2874 let mut attempt_status = attempt_status;
2875 let verified = attempt_status == "completed"
2876 && matches!(
2877 report.verdict,
2878 Some(speccoder_core::verify::Verdict::Verified)
2879 );
2880 let visible: Vec<Vec<String>> = match acceptance
2883 .as_ref()
2884 .filter(|a| a.red)
2885 .and_then(|a| a.argv.clone())
2886 {
2887 Some(argv) => vec![argv],
2888 None => report
2889 .acceptance
2890 .as_ref()
2891 .filter(|a| a["oracle"].is_string() && a["red"] == true)
2892 .and_then(|a| a["tests"].as_array().cloned())
2893 .unwrap_or_default()
2894 .iter()
2895 .filter_map(|t| serde_json::from_value::<Vec<String>>(t["argv"].clone()).ok())
2896 .collect(),
2897 };
2898 let visible_argv = visible.first().cloned();
2899 if verified && !run_config.strength.enabled {
2900 stage(
2901 &*live,
2902 task,
2903 Stage::Strength,
2904 StageState::Skipped,
2905 Some("strength = false".to_owned()),
2906 None,
2907 );
2908 }
2909 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
2910 use speccoder_core::strength::{Gate, gate, over_strict};
2911 let began = std::time::Instant::now();
2912 stage(
2913 &*live,
2914 task,
2915 Stage::Strength,
2916 StageState::Started,
2917 None,
2918 None,
2919 );
2920 let fin = worktree::checkpoint(&wt_root, &tmp);
2921 let patch = fin
2922 .as_ref()
2923 .ok()
2924 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
2925 .unwrap_or_default();
2926 let mut notes = Vec::new();
2927 let held_set: Option<(std::path::PathBuf, String)> = acceptance
2930 .as_ref()
2931 .and_then(|a| a.held_out.clone())
2932 .map(|sha| (held_out_store.clone(), sha))
2933 .or_else(|| {
2934 story_oracle
2935 .as_ref()
2936 .filter(|_| {
2937 matches!(judged_by, Some(speccoder_core::story::Judged::Checkpoint))
2938 })
2939 .and_then(|o| o.held_out.as_ref())
2940 .map(|h| {
2941 (
2942 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &h.key),
2943 h.manifest_sha256.clone(),
2944 )
2945 })
2946 });
2947 let held: Vec<(crate::held_out::HeldOutFile, String)> = match held_set {
2948 None => Vec::new(),
2949 Some((store, sha)) => match crate::held_out::load_verified(&store, &sha) {
2950 Ok((manifest, contents)) => manifest
2951 .files
2952 .into_iter()
2953 .zip(contents.into_iter().map(|(_, c)| c))
2954 .collect(),
2955 Err(t) => {
2956 notes.push(format!("held-out set not overlaid: {}", t.0));
2957 Vec::new()
2958 }
2959 },
2960 };
2961 let rulings = crate::strength::rulings(&strength_dir);
2962 let mut commands = if visible.is_empty() {
2963 vec![argv]
2964 } else {
2965 visible.clone()
2966 };
2967 let measure = |commands: &[Vec<String>]| {
2968 crate::strength::measure(
2969 &crate::strength::Setup {
2970 wt_root: &wt_root,
2971 project: &project,
2972 tmp: &tmp,
2973 runner: &runner,
2974 policy: &policy,
2975 acceptance: commands,
2976 held_out: &held,
2977 mutation: mutation_config.as_ref(),
2978 patch: &patch,
2979 config: &run_config.strength,
2980 keep: &strength_dir,
2981 rulings: &rulings,
2982 },
2983 |changed| {
2984 let input = substrate::near_miss::input(
2986 &bundle_value,
2987 changed,
2988 &crate::mutation::source_patch(&patch),
2989 run_config.strength.near_miss,
2990 run_config.strength.permitted,
2991 );
2992 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
2993 Ok(o) => {
2994 let out = o
2995 .output
2996 .as_ref()
2997 .map(|v| v.peek().clone())
2998 .unwrap_or_default();
2999 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
3000 && !out["answer"].is_null();
3001 crate::strength::Authored {
3002 answer: if ok {
3003 Ok(out["answer"].clone())
3004 } else {
3005 Err(format!(
3006 "the variant author did not answer ({})",
3007 crate::substrate::why_not(&o.status)
3008 ))
3009 },
3010 run_id: Some(o.run_id.to_string()),
3011 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
3012 }
3013 }
3014 Err(e) => crate::strength::Authored {
3015 answer: Err(redact(&e.0, secret)),
3016 run_id: None,
3017 usage: None,
3018 },
3019 }
3020 },
3021 )
3022 };
3023 let mut measured = measure(&commands);
3024 let min = run_config.strength.min;
3027 if let (Ok(a), Ok(fin)) = (&measured, &fin)
3028 && gate(min, &a.combined, false) == Gate::Failed
3029 && over_strict(&a.permitted).is_empty()
3030 {
3031 let visible_tests: Vec<(String, String)> = report
3032 .acceptance
3033 .as_ref()
3034 .and_then(|a| a["files"].as_array().cloned())
3035 .unwrap_or_default()
3036 .iter()
3037 .filter_map(|p| p.as_str())
3038 .map(|p| {
3039 (
3040 p.to_owned(),
3041 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
3042 )
3043 })
3044 .collect();
3045 let setup = AcceptanceSetup {
3046 rt: &rt,
3047 plane: &plane,
3048 project: &project,
3049 worktree: &wt_root,
3050 tmp: &tmp,
3051 policy: &policy,
3052 feature: &feature_name,
3053 task,
3054 bundle: bundle_value.clone(),
3055 scenarios: &scenarios,
3056 instructions: &instructions,
3057 runner: &runner,
3058 layout: Some(&test_layout),
3059 told: &told,
3060 };
3061 let weak = crate::strength::weak(a);
3062 match strengthen(&setup, &visible_tests, weak, &before, fin) {
3063 Ok(added) => {
3064 if let Some(acc) = report.acceptance.as_mut() {
3065 if let Some(files) = acc["files"].as_array_mut() {
3066 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
3067 }
3068 acc["strengthened"] = serde_json::json!({
3069 "files": added.files,
3070 "argv": added.argv,
3071 "run_id": added.report["run_id"],
3072 "usage": added.report["usage"],
3073 });
3074 }
3075 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
3076 commands.push(added.argv);
3077 let mut again = measure(&commands);
3078 if let Ok(b) = again.as_mut() {
3079 b.gate = gate(min, &b.combined, true);
3080 b.notes.insert(
3081 0,
3082 "strengthened once with added acceptance tests".to_owned(),
3083 );
3084 }
3085 measured = again;
3086 }
3087 Err(why) => {
3088 if let Ok(a) = measured.as_mut() {
3089 a.notes
3090 .insert(0, format!("could not strengthen the set: {why}"));
3091 }
3092 }
3093 }
3094 }
3095 match measured {
3096 Ok(mut a) => {
3097 if a.gate == Gate::Off {
3098 a.gate = gate(min, &a.combined, false);
3099 }
3100 a.notes.extend(notes);
3101 let findings = over_strict(&a.permitted);
3102 if !findings.is_empty() {
3103 attempt_status = "over_strict".to_owned();
3104 report.reason = Some(format!(
3105 "an acceptance test rejects an implementation the spec appears to allow: {}",
3106 findings
3107 .iter()
3108 .map(|p| format!("{} ({})", p.scenario, p.rationale))
3109 .collect::<Vec<_>>()
3110 .join("; ")
3111 ));
3112 } else if a.gate == Gate::Failed {
3113 attempt_status = "acceptance_weak".to_owned();
3114 report.reason = Some(format!(
3115 "acceptance set too weak: strength {} is below min_strength {}{}",
3116 speccoder_core::strength::summary(&a.combined, None),
3117 min.unwrap_or_default(),
3118 a.notes
3119 .first()
3120 .map(|n| format!(" ({n})"))
3121 .unwrap_or_default()
3122 ));
3123 } else {
3124 crate::strength::release(&strength_dir);
3125 }
3126 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
3128 report.strength = Some(a);
3129 }
3130 Err(e) => {
3131 report.integrity.push(e.clone());
3132 report.reason = Some(e);
3133 report.verdict = None;
3134 attempt_status = "integrity".to_owned();
3135 }
3136 }
3137 report.attempt_status.clone_from(&attempt_status);
3138 let held_up = attempt_status == "completed";
3139 stage(
3140 &*live,
3141 task,
3142 Stage::Strength,
3143 if held_up {
3144 StageState::Passed
3145 } else {
3146 StageState::Failed
3147 },
3148 report.strength.as_ref().map(|a| {
3149 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
3150 }),
3151 Some(began),
3152 );
3153 }
3154
3155 let verdict_passed = attempt_status == "completed"
3159 && matches!(
3160 report.verdict,
3161 Some(
3162 speccoder_core::verify::Verdict::Verified
3163 | speccoder_core::verify::Verdict::RegressionOnly
3164 )
3165 );
3166 let mut suspected = false;
3167 if !run_config.monitor && verdict_passed {
3168 stage(
3169 &*live,
3170 task,
3171 Stage::Monitor,
3172 StageState::Skipped,
3173 Some("not enabled".to_owned()),
3174 None,
3175 );
3176 }
3177 if run_config.monitor && verdict_passed {
3178 let began = std::time::Instant::now();
3179 stage(
3180 &*live,
3181 task,
3182 Stage::Monitor,
3183 StageState::Started,
3184 None,
3185 None,
3186 );
3187 let patch = worktree::checkpoint(&wt_root, &tmp)
3188 .and_then(|now| worktree::patch(&wt_root, &start, &now))
3189 .unwrap_or_default();
3190 let calls: Vec<serde_json::Value> = report
3191 .tool_calls
3192 .iter()
3193 .map(|c| {
3194 let tool = c["tool"].as_str().unwrap_or_default();
3195 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
3196 })
3197 .collect();
3198 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
3199 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
3200 report.monitor = Some(monitor);
3201 suspected = suspicion;
3202 stage(
3203 &*live,
3204 task,
3205 Stage::Monitor,
3206 if suspected {
3207 StageState::Failed
3208 } else {
3209 StageState::Passed
3210 },
3211 suspected.then(|| "suspects gaming".to_owned()),
3212 Some(began),
3213 );
3214 }
3215
3216 let in_place_stop = options
3219 .session
3220 .as_ref()
3221 .filter(|_| verdict_passed && !suspected)
3222 .and_then(|s| {
3223 s.lock()
3224 .ok()
3225 .and_then(|s| s.check_before_tick(&policy).err())
3226 });
3227 if let Some(e) = &in_place_stop {
3228 report.reason = Some(e.line());
3229 report.verdict = None;
3230 attempt_status = e.code.to_lowercase().replace('-', "_");
3232 report.attempt_status.clone_from(&attempt_status);
3233 }
3234 let passed = verdict_passed && !suspected && in_place_stop.is_none();
3235 if passed
3239 && matches!(
3240 report.verdict,
3241 Some(speccoder_core::verify::Verdict::RegressionOnly)
3242 )
3243 {
3244 use speccoder_core::regression::{Regression, RegressionFacts, RegressionReason, decide};
3245 let facts = RegressionFacts {
3246 acceptance_on: run_config.acceptance,
3247 story: acceptance_story
3249 .clone()
3250 .or_else(|| bug_fix.as_ref().map(|f| f.bug.requirement.clone()))
3251 .or_else(|| parsed_task.as_ref().and_then(|t| t.story.clone())),
3252 scenarios: scenarios.len(),
3253 skip: acceptance.as_ref().and_then(|a| a.skip_cause.clone()),
3254 red_class: acceptance.as_ref().and_then(|a| {
3256 if a.red
3257 && report
3258 .acceptance
3259 .as_ref()
3260 .is_some_and(|r| r["green_flaky"] == true)
3261 {
3262 Some(speccoder_core::verify::RedClass::Flaky)
3263 } else {
3264 a.red_class
3265 }
3266 }),
3267 };
3268 let reason = if planning.is_some() {
3269 RegressionReason::PlanningWork
3270 } else if in_story && oracle_empty {
3271 RegressionReason::AuthorWroteNone
3272 } else if in_story {
3273 match &report.progress {
3275 Some(
3277 speccoder_core::story::Progress::Closed
3278 | speccoder_core::story::Progress::InProgress,
3279 ) if story_oracle
3280 .as_ref()
3281 .is_some_and(super::story::StoryOracle::green_from_the_start) =>
3282 {
3283 RegressionReason::AlreadyPassing
3284 }
3285 Some(speccoder_core::story::Progress::Closed) => RegressionReason::StoryVerified,
3286 _ if super::story::unchecked(&report) => RegressionReason::RegressionUnchecked,
3287 _ => RegressionReason::InProgress,
3288 }
3289 } else {
3290 decide(&facts)
3291 };
3292 report.regression = Some(Regression::of(reason));
3293 }
3294 let recorded_from = report.integrity.len();
3295 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
3296 let began = std::time::Instant::now();
3297 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3298 let message = tick_message(&TickFacts {
3299 feature: &feature_name,
3300 task,
3301 title: &t.title,
3302 report: &report,
3303 scenarios: &story_scenarios,
3304 red: acceptance
3305 .as_ref()
3306 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red()))
3307 .or_else(|| story_red(&report)),
3308 checklists_overridden,
3309 extra: bug_fix
3310 .as_ref()
3311 .map(|f| {
3312 super::bug::trailers(
3313 f,
3314 &report
3315 .verdict
3316 .as_ref()
3317 .map(super::outcome::verdict_word)
3318 .unwrap_or_default(),
3319 &crate::strength::rulings(&strength_dir),
3320 )
3321 })
3322 .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
3324 .unwrap_or_default()
3325 + &ruling_trailers(&ruled),
3326 });
3327 match &options.session {
3328 None => {
3330 match format.tick(file, task, &t.line_hash) {
3331 Ok(()) => report.ticked = true,
3332 Err(e) => report.integrity.push(format!("tick refused: {e}")),
3333 }
3334 if report.ticked && run_config.commit_per_task {
3335 match commit_attempt(&wt_root, &message) {
3336 Ok(id) => report.commit = Some(id),
3337 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3338 }
3339 }
3340 }
3341 Some(session) => {
3344 let digest =
3345 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
3346 if let Ok(mut s) = session.lock() {
3347 let done = s.commit_and_tick(
3348 &plane_dir,
3349 file,
3350 &t.line_hash,
3351 &message,
3352 &digest,
3353 || format.tick(file, task, &t.line_hash),
3354 );
3355 report.ticked = done.ticked;
3356 report.commit = done.commit;
3357 report.integrity.extend(done.problems);
3358 }
3359 }
3360 }
3361 report.instructions = attempt_instructions
3362 .iter()
3363 .map(|i| speccoder_core::InstructionFile {
3364 path: i.path.clone(),
3365 sha256: i.sha256.clone(),
3366 bytes: i.bytes,
3367 cut: i.cut,
3368 })
3369 .collect();
3370 if let Some(commit) = report
3372 .commit
3373 .clone()
3374 .filter(|_| report.ticked && run_config.attest.enabled)
3375 {
3376 let verdict = report
3377 .verdict
3378 .as_ref()
3379 .map(super::outcome::verdict_word)
3380 .unwrap_or_default();
3381 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3382 let feature_dir = snap
3383 .feature
3384 .as_ref()
3385 .map(|f| f.feature.dir.clone())
3386 .unwrap_or_default();
3387 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
3388 let tick = crate::attest::Tick {
3389 repo: &wt_root,
3390 config: &config_text,
3391 attest: &run_config.attest,
3392 format: &format!("{:?}", format.kind()).to_lowercase(),
3393 feature: &feature_name,
3394 feature_dir: &feature_dir,
3395 task,
3396 title: &t.title,
3397 line_hash: &t.line_hash,
3398 section: t.story.as_deref(),
3399 section_text: bundle_value["story"].as_str().unwrap_or_default(),
3400 requirements: &requirements,
3401 commit: &commit,
3402 verdict: &verdict,
3403 sandbox: serde_json::json!({
3404 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3405 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3406 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3407 }),
3408 executor: serde_json::json!({
3409 "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()) },
3410 "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
3411 }),
3412 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3413 bug: bug_fix
3414 .as_ref()
3415 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3416 };
3417 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3418 }
3419 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
3420 stage(
3421 &*live,
3422 task,
3423 Stage::Commit,
3424 if committed {
3425 StageState::Passed
3426 } else {
3427 StageState::Failed
3428 },
3429 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3430 Some(began),
3431 );
3432 }
3433 if passed && let Some(n) = &native {
3436 let began = std::time::Instant::now();
3437 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
3438 let verdict = report
3439 .verdict
3440 .as_ref()
3441 .map(super::outcome::verdict_word)
3442 .unwrap_or_default();
3443 let message = tick_message(&TickFacts {
3444 feature: &feature_name,
3445 task,
3446 title: &n.title,
3447 report: &report,
3448 scenarios: &story_scenarios,
3449 red: acceptance
3450 .as_ref()
3451 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red()))
3452 .or_else(|| story_red(&report)),
3453 checklists_overridden: false,
3454 extra: bug_fix
3455 .as_ref()
3456 .map(|f| {
3457 super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
3458 })
3459 .unwrap_or_default()
3460 + &ruling_trailers(&ruled),
3461 });
3462 match commit_attempt(&wt_root, &message) {
3463 Ok(id) => report.commit = Some(id),
3464 Err(e) => report.integrity.push(format!("commit failed: {e}")),
3465 }
3466 if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
3467 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
3468 let tick = crate::attest::Tick {
3469 repo: &wt_root,
3470 config: &config_text,
3471 attest: &run_config.attest,
3472 format: &format!("{:?}", format.kind()).to_lowercase(),
3473 feature: &feature_name,
3474 feature_dir: &n.dir,
3475 task,
3476 title: &n.title,
3477 line_hash: "",
3478 section: None,
3479 section_text: &n.story,
3480 requirements: &[],
3481 commit: &commit,
3482 verdict: &verdict,
3483 sandbox: serde_json::json!({
3484 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
3485 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
3486 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
3487 }),
3488 executor: serde_json::json!({
3489 "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
3490 }),
3491 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
3492 bug: bug_fix
3493 .as_ref()
3494 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
3495 };
3496 report.attestation = Some(crate::attest::record_tick(&tick, &report));
3497 }
3498 stage(
3499 &*live,
3500 task,
3501 Stage::Commit,
3502 if report.commit.is_some() {
3503 StageState::Passed
3504 } else {
3505 StageState::Failed
3506 },
3507 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
3508 Some(began),
3509 );
3510 }
3511
3512 let unrecorded = report.integrity[recorded_from..]
3515 .iter()
3516 .find(|m| m.starts_with("tick refused") || m.starts_with("commit failed"))
3517 .cloned();
3518 let passed = passed && unrecorded.is_none();
3519 if let Some(why) = unrecorded {
3520 attempt_status = "tick_failed".to_owned();
3521 report.ticked = false;
3522 report.reason = Some(why);
3523 }
3524 if attempt_status == "repro_weak"
3529 && let Some(weak) = &bug_weak
3530 && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
3531 {
3532 report
3533 .integrity
3534 .push(format!("holding the attempt failed: {e}"));
3535 }
3536 if attempt_status == "over_strict" || attempt_status == "repro_weak" {
3537 let findings = report
3538 .strength
3539 .as_ref()
3540 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
3541 .unwrap_or_default();
3542 if attempt_status == "over_strict"
3543 && let Err(e) = crate::strength::hold(&strength_dir, &findings)
3544 {
3545 report
3546 .integrity
3547 .push(format!("holding the attempt failed: {e}"));
3548 }
3549 marker_guard.keep();
3550 }
3551 if report.ticked && !ruled.is_empty() {
3553 let _ = crate::acceptance_at::acted_on_task(&s.plane_dir, &feature_name, task);
3554 }
3555 let mut conflict: Option<String> = None;
3557 if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
3560 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
3561 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
3562 let reason = redact(&reason, secret);
3563 let story_open: Vec<String> = match &report.progress {
3568 Some(speccoder_core::story::Progress::StoryOpen { scenarios }) => scenarios.clone(),
3569 _ if outcome == "blocked" => story_oracle
3570 .as_ref()
3571 .and_then(|o| {
3572 let named = frozen_test_named(report.reason.as_deref()?)?;
3573 o.tests
3574 .iter()
3575 .find(|t| t.path == named)
3576 .map(|t| t.scenarios.clone())
3577 })
3578 .unwrap_or_default(),
3579 _ => Vec::new(),
3580 };
3581 let own_files: Vec<String> = report
3584 .acceptance
3585 .as_ref()
3586 .filter(|_| story_oracle.is_none() && story_key.is_none())
3588 .and_then(|a| a["files"].as_array())
3589 .into_iter()
3590 .flatten()
3591 .filter_map(|p| p.as_str().map(str::to_owned))
3592 .collect();
3593 let own_failed = report.verification.as_ref().is_some_and(|v| {
3594 v.checks
3595 .iter()
3596 .any(|c| c.name == "acceptance" && c.status.is_failure())
3597 });
3598 let named = (outcome == "blocked")
3599 .then(|| frozen_test_named(report.reason.as_deref()?))
3600 .flatten();
3601 let own_test = named
3602 .filter(|n| own_files.contains(n))
3603 .or_else(|| own_failed.then(|| own_files.first().cloned()).flatten());
3604 let record = attempts::Record {
3605 run,
3606 outcome: &outcome,
3607 reason: &reason,
3608 pending: report.pending.as_ref(),
3609 usage: report.usage.as_ref(),
3610 story_open: &story_open,
3611 own_test: own_test.as_deref(),
3612 };
3613 conflict.clone_from(&own_test);
3614 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
3615 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
3616 {
3617 Ok(saved) => report.saved_attempt = Some(saved.reference),
3618 Err(e) => report
3619 .integrity
3620 .push(format!("saving the attempt failed: {}", e.0)),
3621 }
3622 }
3623
3624 let (status, mut message) = status_and_message(&report, &attempt_status, suspected);
3625 if status == CommandStatus::RunFailed
3628 && let Some(region) = s.model.region.as_deref()
3629 && report
3630 .reason
3631 .as_deref()
3632 .and_then(substrate::provider_refusal)
3633 .is_some()
3634 {
3635 message.push_str(&format!(
3636 " — Amazon Bedrock, region {region}, model {}",
3637 s.model.model
3638 ));
3639 }
3640 let refusal = |result: CommandResult| {
3643 if !result.has_explicit_code()
3644 && refused_account
3645 && result.status() == CommandStatus::RunFailed
3646 {
3647 result.with_code("PROVIDER-REFUSED").with_fix(
3648 substrate::model_config::provider_refused_fix(
3649 &s.model,
3650 crate::aws::detect_here().as_ref(),
3651 feature,
3652 ),
3653 )
3654 } else {
3655 result
3656 }
3657 };
3658 let offered = crate::ladder::offer_for(
3660 path,
3661 &worktree::project_state_dir(state, path),
3662 &format!("{feature_name}/{task}"),
3663 )
3664 .map(|o| progress::Event::LadderOffered {
3665 unit: o.unit,
3666 task: o.task,
3667 signals: o.signals.into_iter().map(|s| s.id).collect(),
3668 });
3669 if let Some(fix) = bug_fix.as_mut() {
3670 let done = refusal(super::bug::finished(
3671 &bug_ctx,
3672 fix,
3673 report,
3674 &attempt_status,
3675 passed,
3676 status,
3677 redact(&message, secret),
3678 ));
3679 if let Some(e) = &offered {
3680 live.event(e);
3681 }
3682 return done;
3683 }
3684 live.event(&finished_event(
3685 task,
3686 &report,
3687 &attempt_status,
3688 suspected,
3689 redact(&message, secret),
3690 ));
3691 if let Some(e) = &offered {
3692 live.event(e);
3693 }
3694 let code = collection_code(&report, &attempt_status).or_else(|| {
3696 matches!(
3697 report.progress,
3698 Some(speccoder_core::story::Progress::StoryOpen { .. })
3699 )
3700 .then_some("STORY-OPEN")
3701 });
3702 let story_open_fix: Vec<String> = match &report.progress {
3703 Some(speccoder_core::story::Progress::StoryOpen { scenarios }) if !report.ticked => {
3704 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3705 std::iter::once(format!("speccoder task nudge {task}{f} --note \"…\""))
3706 .chain(scenarios.iter().map(|id| {
3707 format!(
3708 "speccoder task rule {task}{f} --tests-wrong --scenario {id} --reason \"…\""
3709 )
3710 }))
3711 .collect()
3712 }
3713 _ => match &conflict {
3715 Some(path) if !report.ticked => {
3716 let f = feature.map_or_else(String::new, |f| format!(" --feature {f}"));
3717 message.push_str(&format!(
3718 " — its own test `{path}` failed: if it asserts what the spec does not say, rule it wrong"
3719 ));
3720 vec![
3721 format!("speccoder task rule {task}{f} --tests-wrong --reason \"…\""),
3722 format!("speccoder task nudge {task}{f} --note \"…\""),
3723 ]
3724 }
3725 _ => Vec::new(),
3726 },
3727 };
3728 let result = CommandResult::new("run", status, &redact(&message, secret));
3729 let result = if story_open_fix.is_empty() {
3730 result
3731 } else {
3732 result.with_fix(story_open_fix)
3733 };
3734 match code {
3735 Some(c) => result.with_code(c),
3736 None => refusal(result),
3737 }
3738 .with_data(CommandData::Run(Box::new(report)))
3739}
3740
3741fn collection_code(
3744 report: &speccoder_core::RunReport,
3745 attempt_status: &str,
3746) -> Option<&'static str> {
3747 if attempt_status == "collection_unknown" {
3748 return Some("TESTS-COLLECTION-UNKNOWN");
3749 }
3750 match &report.verdict {
3751 Some(speccoder_core::verify::Verdict::Failed { reasons })
3752 if reasons
3753 .first()
3754 .is_some_and(|r| r.starts_with(speccoder_core::collection::REASON_PREFIX)) =>
3755 {
3756 Some("TESTS-NOT-COLLECTED")
3757 }
3758 _ => None,
3759 }
3760}
3761
3762enum Collected {
3768 Unknown(String),
3770 Changed(String),
3772}
3773
3774fn judge_collection(
3775 dirs: (&Path, &Path, &Path),
3776 runner: &sandbox::Runner,
3777 otherwise_passing: bool,
3778 report: &mut speccoder_core::RunReport,
3779 live: &dyn progress::Progress,
3780 task: &str,
3781) -> Option<Collected> {
3782 let (project, wt_root, tmp) = dirs;
3783 let before = worktree::checkpoint(wt_root, tmp).ok();
3785 let checks = std::fs::read_to_string(project.join(crate::verify::config::FILE))
3786 .ok()
3787 .and_then(|t| crate::verify::config::parse(&t).ok())
3788 .unwrap_or_default()
3789 .into_iter()
3790 .filter(|c| c.tier == crate::verify::config::Tier::Fast)
3791 .collect::<Vec<_>>();
3792 let results = report
3793 .verification
3794 .as_ref()
3795 .map(|v| v.checks.clone())
3796 .unwrap_or_default();
3797 let c = crate::collection::judge(&crate::collection::Input {
3798 root: project,
3799 changes: &report.changes,
3800 checks: &checks,
3801 results: &results,
3802 runner,
3803 listings: otherwise_passing,
3804 })?;
3805 if !c.listing.is_empty()
3806 && let Some(before) = &before
3807 {
3808 let changed: Vec<String> = worktree::checkpoint(wt_root, tmp)
3809 .and_then(|after| worktree::diff(wt_root, before, &after))
3810 .map(|d| {
3811 d.into_iter()
3812 .map(|c| c.path)
3813 .filter(|p| !speccoder_core::collection::is_build_or_dependency(p))
3814 .collect()
3815 })
3816 .unwrap_or_else(|e| vec![format!("(the tree could not be read: {})", e.0)]);
3817 if !changed.is_empty() {
3818 report.collection = Some(c);
3819 return Some(Collected::Changed(format!(
3820 "integrity: a test listing changed the worktree: {}",
3821 changed.join(", ")
3822 )));
3823 }
3824 }
3825 live.event(&progress::Event::CollectionChecked {
3826 task: task.to_owned(),
3827 files: c
3828 .files
3829 .iter()
3830 .map(|f| progress::CollectionFile {
3831 path: f.path.clone(),
3832 state: f.state,
3833 decided_by: f.decided_by,
3834 })
3835 .collect(),
3836 });
3837 let never = c.reasons();
3838 let unknown: Vec<String> = c
3839 .unknown()
3840 .map(|f| {
3841 format!(
3842 "speccoder cannot tell whether the runner ran `{}` ({})",
3843 f.path,
3844 f.why.as_deref().unwrap_or("no report or listing names it")
3845 )
3846 })
3847 .collect();
3848 report.collection = Some(c);
3849 if !never.is_empty() {
3850 let mut reasons = never;
3851 if let Some(speccoder_core::verify::Verdict::Failed { reasons: more }) =
3852 report.verdict.take()
3853 {
3854 reasons.extend(more);
3855 }
3856 report.verdict = Some(speccoder_core::verify::Verdict::Failed { reasons });
3857 return None;
3858 }
3859 (otherwise_passing && !unknown.is_empty()).then(|| Collected::Unknown(unknown.join("; ")))
3860}
3861
3862fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
3865 let said = c
3866 .stderr_tail
3867 .lines()
3868 .chain(c.stdout_tail.lines())
3869 .map(str::trim)
3870 .find(|l| l.starts_with("error"))
3871 .map(|l| l.chars().take(200).collect::<String>());
3872 let fix = match said.as_deref() {
3873 Some(l) if l.contains("profile `speccoder` not found") => Some(
3874 "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
3875 ),
3876 Some(l) if l.contains("no tests to run") => Some(
3877 "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",
3878 ),
3879 Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
3880 "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
3881 ),
3882 _ => None,
3883 };
3884 match (said, fix) {
3885 (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
3886 (Some(s), None) => format!("{note} ({s})"),
3887 _ => note.to_owned(),
3888 }
3889}
3890
3891#[must_use]
3895pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
3896 use speccoder_core::verify::CheckStatus;
3897 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
3898 return None;
3899 }
3900 if let Some(note) = &c.note {
3901 return Some(with_cause(note, c));
3902 }
3903 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
3904 return None;
3905 }
3906 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
3907 let runner = c
3911 .argv
3912 .first()
3913 .map(|a| a.rsplit('/').next().unwrap_or(a))
3914 .is_some_and(|a| {
3915 matches!(
3916 a,
3917 "cargo"
3918 | "pytest"
3919 | "python"
3920 | "python3"
3921 | "uv"
3922 | "npm"
3923 | "npx"
3924 | "pnpm"
3925 | "yarn"
3926 | "bun"
3927 | "go"
3928 | "jest"
3929 | "vitest"
3930 | "mvn"
3931 | "gradle"
3932 | "dotnet"
3933 | "mix"
3934 | "swift"
3935 )
3936 });
3937 let cannot_start = matches!(c.exit_code, Some(126 | 127));
3938 let is_broken = match &summary {
3939 _ if cannot_start => true,
3940 Some(s) if s.build_failed => true,
3941 Some(s) => s.passed + s.failed + s.errors == 0,
3942 None => runner,
3943 };
3944 match is_broken {
3945 false => None,
3946 true => {
3947 let first = c
3948 .stderr_tail
3949 .lines()
3950 .chain(c.stdout_tail.lines())
3951 .map(str::trim)
3952 .find(|l| l.to_lowercase().contains("error"))
3953 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
3954 .unwrap_or("it failed without running a test")
3955 .chars()
3956 .take(160)
3957 .collect::<String>();
3958 Some(first)
3959 }
3960 }
3961}
3962
3963fn login(
3967 fail: impl FnOnce(CommandStatus, String) -> CommandResult,
3968 shown: &str,
3969 agent: &crate::acp::agent::Agent,
3970 ways: &[crate::acp::session::SignIn],
3971) -> CommandResult {
3972 let words: Vec<String> = ways
3973 .iter()
3974 .filter(|w| w.command.is_none())
3975 .map(|w| {
3976 format!(
3977 "→ {}",
3978 w.description.clone().unwrap_or_else(|| w.name.clone())
3979 )
3980 })
3981 .collect();
3982 let mut commands: Vec<Vec<String>> = ways
3983 .iter()
3984 .filter_map(|w| w.command.as_deref())
3985 .map(|c| c.split_whitespace().map(str::to_owned).collect())
3986 .collect();
3987 if words.is_empty()
3988 && commands.is_empty()
3989 && let Some(p) = agent.preset()
3990 {
3991 commands.push(p.login.split_whitespace().map(str::to_owned).collect());
3992 }
3993 commands.dedup();
3994 let argvs: Vec<speccoder_core::step::Argvs> = commands.into_iter().map(|c| vec![c]).collect();
3995 let mut fix: Vec<String> = argvs
3996 .iter()
3997 .map(|a| speccoder_core::step::render(a))
3998 .collect();
3999 fix.extend(words);
4000 fix.push("speccoder doctor".to_owned());
4001 fail(
4002 CommandStatus::NeedsHuman,
4003 format!("{shown} needs signing in before it can make the attempt; nothing was attempted"),
4004 )
4005 .with_code("EXECUTOR-LOGIN")
4006 .with_fix(fix)
4007 .with_commands(argvs)
4008}
4009
4010fn story_red(report: &speccoder_core::RunReport) -> Option<speccoder_core::verify::RedClass> {
4013 let a = report
4014 .acceptance
4015 .as_ref()
4016 .filter(|a| a["oracle"].is_string())?;
4017 serde_json::from_value::<speccoder_core::verify::RedClass>(a["red_class"].clone())
4018 .ok()
4019 .filter(|c| c.is_red())
4020}
4021
4022fn with_oracle(bundle: &serde_json::Value, oracle: &serde_json::Value) -> serde_json::Value {
4025 let mut b = bundle.clone();
4026 if !oracle.is_null() {
4027 b["oracle"] = oracle.clone();
4028 }
4029 b
4030}
4031
4032fn ruling_trailers(ruled: &[crate::acceptance_at::Ruling]) -> String {
4035 ruled
4036 .iter()
4037 .map(|r| {
4038 format!(
4039 "Ruling: tests-wrong by {} — {}\n",
4040 r.by,
4041 r.reason.replace('\n', " ")
4042 )
4043 })
4044 .collect()
4045}
4046
4047fn frozen_test_named(reason: &str) -> Option<String> {
4050 Some(
4051 reason
4052 .split("frozen test `")
4053 .nth(1)?
4054 .split('`')
4055 .next()?
4056 .trim_start_matches("./")
4057 .to_owned(),
4058 )
4059}