1use std::path::Path;
4use std::sync::Arc;
5
6use speccoder_core::stage::{Stage, StageState};
7use speccoder_core::{CommandData, CommandResult, CommandStatus};
8
9use crate::pipeline::*;
10use crate::{
11 approvals, attempts, external, git, integrity, mcp, mutation, progress, resume, sandbox,
12 substrate, tools, verify, worktree,
13};
14
15#[must_use]
18pub fn run(path: &Path, feature: Option<&str>, task: &str, options: &RunOptions) -> CommandResult {
19 let Some(state) = worktree::state_dir() else {
20 return CommandResult::new(
21 "run",
22 CommandStatus::ConfigError,
23 "no state directory (set SPECCODER_STATE_DIR or HOME)",
24 )
25 .with_code("NO-STATE-DIR");
26 };
27 let model = match setup(path, feature, &state, "run") {
28 Ok(s) => s.model,
29 Err(r) => return *r,
30 };
31 let (driver, key) = match substrate::model_config::open_driver(&model) {
32 Ok((d, k)) => (Ok::<_, String>(d), k.unwrap_or_default()),
33 Err(e) => return substrate::model_config::refused("run", &e),
34 };
35 match driver {
36 Ok(provider) => run_task(path, feature, task, &state, provider, Some(&key), options),
37 Err(e) => CommandResult::new("run", CommandStatus::ConfigError, &redact(&e, Some(&key))),
38 }
39}
40
41pub fn run_with(
43 path: &Path,
44 feature: Option<&str>,
45 task: &str,
46 state: &Path,
47 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
48 secret: Option<&str>,
49) -> CommandResult {
50 run_task(
51 path,
52 feature,
53 task,
54 state,
55 provider,
56 secret,
57 &RunOptions::default(),
58 )
59}
60
61#[must_use]
63pub fn run_with_options(
64 path: &Path,
65 feature: Option<&str>,
66 task: &str,
67 state: &Path,
68 provider: Arc<dyn agentplane::model::ModelProvider>,
69 secret: Option<&str>,
70 options: &RunOptions,
71) -> CommandResult {
72 run_task(path, feature, task, state, provider, secret, options)
73}
74
75#[must_use]
77pub fn run_with_approver(
78 path: &Path,
79 feature: Option<&str>,
80 task: &str,
81 state: &Path,
82 provider: Arc<dyn agentplane::model::ModelProvider>,
83 secret: Option<&str>,
84 approver: Arc<dyn approvals::Approver>,
85) -> CommandResult {
86 let options = RunOptions {
87 approver: Some(approver),
88 ..RunOptions::default()
89 };
90 run_task(path, feature, task, state, provider, secret, &options)
91}
92
93#[must_use]
96pub fn run_native_bug(
97 root: &Path,
98 bug: &speccoder_core::bug::BugReport,
99 state: &Path,
100 provider: Arc<dyn agentplane::model::ModelProvider>,
101 secret: Option<&str>,
102 options: &RunOptions,
103) -> CommandResult {
104 let options = RunOptions {
105 bug_home: Some(Arc::new(super::bug::NativeBug::of(bug))),
106 ..options.clone()
107 };
108 run_task(root, None, &bug.task, state, provider, secret, &options)
109}
110
111#[allow(clippy::too_many_lines)] pub(crate) fn run_task(
113 path: &Path,
114 feature: Option<&str>,
115 task: &str,
116 state: &Path,
117 provider: std::sync::Arc<dyn agentplane::model::ModelProvider>,
118 secret: Option<&str>,
119 options: &RunOptions,
120) -> CommandResult {
121 use speccoder_core::RunReport;
122 if options.in_place && options.session.is_none() {
124 return in_session("run", path, feature, state, options, |o| {
125 run_task(path, feature, task, state, Arc::clone(&provider), secret, o)
126 });
127 }
128 let approver = options.approver.clone();
129 use speccoder_core::snapshot::TaskState;
130
131 let refuse =
132 |status: CommandStatus, m: String| CommandResult::new("run", status, &redact(&m, secret));
133
134 let s = match setup(path, feature, state, "run") {
136 Ok(s) => s,
137 Err(r) => return *r,
138 };
139 let native = options.bug_home.clone();
141 let feature_name = match (&native, s.snapshot.feature.as_ref()) {
142 (Some(n), _) => n.unit.clone(),
143 (None, Some(f)) => f.feature.name.clone(),
144 (None, None) => {
145 return refuse(
146 CommandStatus::SelectionNeeded,
147 "no feature selected; pass --feature".to_owned(),
148 );
149 }
150 };
151 let plane_dir = match &options.lane {
154 Some(lane) => s.plane_dir.join("lanes").join(lane),
155 None => s.plane_dir.clone(),
156 };
157 let live: Arc<dyn progress::Progress> = Arc::new(progress::Live::new(
159 &plane_dir,
160 s.model.prices,
161 options.progress.clone(),
162 ));
163
164 let wt = match &native {
166 Some(n) => crate::commands::worktree_of(&s.snapshot, &n.unit, &n.dir, state),
167 None => workspace(path, Some(&feature_name), state, options),
168 };
169 let Some(CommandData::Worktree(w)) = wt.data() else {
170 return CommandResult::carried("run", &wt);
171 };
172 if wt.status() != CommandStatus::Ok {
173 return CommandResult::carried("run", &wt).with_data(CommandData::Worktree(w.clone()));
174 }
175 let (Some(wt_path), Some(project_dir)) = (w.path.clone(), w.project_dir.clone()) else {
176 return refuse(CommandStatus::Error, "worktree without a path".to_owned());
177 };
178 let (wt_path, project_dir) = match &options.lane {
179 None => (wt_path, project_dir),
180 Some(lane) => match lane_worktree(path, state, &feature_name, lane, &wt_path, &project_dir)
181 {
182 Ok(paths) => paths,
183 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
184 },
185 };
186 let project = std::path::PathBuf::from(&project_dir);
187 let wt_root = std::path::PathBuf::from(&wt_path);
188 let tmp = plane_dir.join("tmp");
189
190 if options.lane.is_none()
193 && let Some((other, covers)) =
194 crate::approvals::waiting_elsewhere(&project, &plane_dir, &feature_name, task)
195 {
196 return refuse(
197 CommandStatus::NeedsHuman,
198 format!(
199 "{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}`"
200 ),
201 );
202 }
203 if options.lane.is_none()
205 && let Some(other) = crate::strength::held_elsewhere(&plane_dir, &feature_name, task)
206 {
207 return refuse(
208 CommandStatus::NeedsHuman,
209 format!(
210 "{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`"
211 ),
212 )
213 .with_code("TESTS-OVER-STRICT");
214 }
215 let commits_per_task = verify::find_config(&project)
219 .and_then(|root| std::fs::read_to_string(root.join(verify::config::FILE)).ok())
220 .and_then(|t| substrate::run_config::parse(&t).ok())
221 .is_none_or(|c| c.commit_per_task);
222 let marker_path = resume::path(&plane_dir, &feature_name, task);
225 if options.fresh_acceptance && options.lane.is_none() {
228 let _ = std::fs::remove_file(&marker_path);
229 crate::strength::release(&crate::strength::dir(&s.plane_dir, &feature_name, task));
230 }
231 let head_now = git::Git::at(&wt_root)
232 .line(&["rev-parse", "HEAD"])
233 .unwrap_or_default();
234 let autopilot = options.mode == speccoder_core::oracle::RunMode::Autopilot;
239 let interrupted = resume::load(&marker_path).filter(|m| {
240 m.head == head_now && options.lane.is_none() && !head_now.is_empty() && !autopilot
241 });
242 if let Some(m) = &interrupted {
245 if let (Some(id), Ok(mut run)) = (&m.run_id, options.run_id.lock()) {
246 *run = crate::pipeline::RunId(id.clone());
247 }
248 if let Ok(mut asked) = options.asked.lock() {
249 *asked = (*asked).max(m.asked);
250 }
251 if let Ok(mut g) = options.run_grants.lock() {
252 for o in &m.run_grants {
253 if !g.contains(o) {
254 g.push(o.clone());
255 }
256 }
257 }
258 }
259 let marker_guard = resume::Guard::new(marker_path.clone());
260 let resumed_from = interrupted.as_ref().map(|_| {
263 std::fs::read_to_string(plane_dir.join(progress::FILE))
264 .ok()
265 .and_then(|t| serde_json::from_str::<progress::LiveState>(&t).ok())
266 .filter(|l| l.task == task)
267 .map_or((None, None), |l| (l.stage, l.last_call))
268 });
269 let mut set_aside = None;
270 if interrupted.is_none()
272 && commits_per_task
273 && options.session.is_none()
274 && worktree::require_clean(&wt_root).is_err()
275 {
276 let left = worktree::leftovers(&wt_root);
280 if !left.is_empty() {
281 let mut what = Vec::new();
282 if !left.commit.is_empty() {
283 what.push(format!("{} is not committed", left.commit.join(", ")));
284 }
285 if !left.ignore.is_empty() {
286 what.push(format!("{} is not git-ignored", left.ignore.join(", ")));
287 }
288 let checkout = std::path::Path::new(&s.snapshot.root);
289 let remedy = crate::code::setup_remedy(checkout, Some(&feature_name));
290 let command = remedy
291 .as_ref()
292 .map_or_else(|| left.fix(checkout, &wt_root, &[]), |r| r.command.clone());
293 return refuse(
294 CommandStatus::GitPrecondition,
295 format!(
296 "{} — the checks write it on every run, so each run would find it in the worktree and set it aside",
297 what.join(" and "),
298 ),
299 )
300 .with_code("SETUP-INCOMPLETE")
301 .with_fix(vec!["speccoder fix".to_owned(), command])
302 .with_remedy(remedy);
303 }
304 match set_aside_leftovers(&wt_root, &tmp, &feature_name, task) {
305 Ok(reference) => set_aside = Some(reference),
306 Err(e) => return refuse(CommandStatus::GitPrecondition, e),
307 }
308 }
309
310 let format = crate::format::at(&project);
312 let Some(snap) = format.inspect(
313 &project,
314 if native.is_some() {
315 None
316 } else {
317 Some(&feature_name)
318 },
319 ) else {
320 return refuse(
321 CommandStatus::NotAProject,
322 format.messages().no_project_in_worktree.to_owned(),
323 );
324 };
325 if native.is_some()
326 && crate::bug::state::load(&s.plane_dir, &feature_name, task)
327 .is_some_and(|b| b.outcome == speccoder_core::bug::Outcome::Fixed)
328 {
329 return refuse(
330 CommandStatus::TaskNotReady,
331 format!("{task} is already fixed"),
332 )
333 .with_code("TASK-DONE");
334 }
335 if native.is_none() {
338 let Some(st) = snap
339 .feature
340 .as_ref()
341 .and_then(|f| f.schedule.as_ref())
342 .and_then(|sch| sch.tasks.iter().find(|t| t.id == task))
343 else {
344 return refuse(
345 CommandStatus::NotFound,
346 format!("no task {task} in {feature_name}"),
347 );
348 };
349 match st.state {
350 TaskState::Done => {
351 return refuse(
352 CommandStatus::TaskNotReady,
353 format!("{task} is already done"),
354 )
355 .with_code("TASK-DONE");
356 }
357 TaskState::Blocked => {
358 return refuse(
359 CommandStatus::TaskNotReady,
360 format!("{task} is blocked by {}", st.blocked_by.join(", ")),
361 )
362 .with_code("TASK-BLOCKED");
363 }
364 TaskState::Ready => {}
365 }
366 }
367 let checklists_overridden = if native.is_some() {
370 false
371 } else {
372 match checklist_gate(&project, Some(&feature_name), options.ignore_checklists) {
373 Ok(overridden) => overridden,
374 Err(stop) => return *stop,
375 }
376 };
377 let parsed_task = snap
378 .feature
379 .as_ref()
380 .and_then(|f| f.tasks.as_ref())
381 .and_then(|t| t.tasks.iter().find(|t| t.id == task).cloned());
382 let tasks_file = snap
383 .feature
384 .as_ref()
385 .and_then(|f| f.tasks.as_ref())
386 .map(|t| project.join(&t.path));
387
388 let bundle = match native
389 .as_ref()
390 .map(|n| n.bundle(&project))
391 .map_or_else(|| format.bundle(&snap, task), Ok)
392 {
393 Ok(b) => b,
394 Err(_) => {
395 return refuse(
396 CommandStatus::NotFound,
397 format!("no task {task} in the worktree's {feature_name}"),
398 );
399 }
400 };
401 let Some(config_root) = verify::find_config(&project) else {
402 return refuse(
403 CommandStatus::ConfigError,
404 "the worktree has no speccoder.toml (is it committed?)".to_owned(),
405 )
406 .with_code("CONFIG-MISSING");
407 };
408 let config_text =
409 std::fs::read_to_string(config_root.join(verify::config::FILE)).unwrap_or_default();
410 let run_config = match substrate::run_config::parse(&config_text) {
411 Ok(c) => c,
412 Err(e) => {
413 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
414 .with_code("CONFIG-INVALID");
415 }
416 };
417 if run_config.review_required
420 && let Some(f) = snap.feature.as_ref()
421 && let Some(t) = crate::review::totals_of(&project, format, &feature_name, &f.feature.dir)
422 && !t.complete()
423 {
424 return refuse(
425 CommandStatus::NeedsHuman,
426 format!(
427 "{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",
428 t.approved, t.open, t.changes_requested, t.lapsed, t.questions
429 ),
430 )
431 .with_code("REVIEW-PENDING");
432 }
433 if run_config.review_required {
436 let ambiguous = match crate::oracle::open_ambiguities(
437 &s.plane_dir,
438 &feature_name,
439 &format.oracle_requirements(&snap),
440 ) {
441 Ok(a) => a,
442 Err(t) => {
443 return refuse(
444 CommandStatus::NeedsHuman,
445 format!(
446 "{}: whether it is read two ways is unknown and [review] required is on",
447 t.0
448 ),
449 )
450 .with_code("ORACLE-TAMPERED");
451 }
452 };
453 if let Some((req, scenario)) = ambiguous.first() {
454 return refuse(
455 CommandStatus::NeedsHuman,
456 format!(
457 "{req} is read two ways ({scenario}) and [review] required is on: choose a reading or reword it first"
458 ),
459 )
460 .with_code("REVIEW-AMBIGUOUS")
461 .with_fix(vec![
462 format!("speccoder review tests {req} --reading A {scenario}"),
463 format!("speccoder review tests {req} --reading B {scenario}"),
464 ]);
465 }
466 }
467 let executor = match external::parse(&config_text) {
468 Ok(e) => e,
469 Err(e) => {
470 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
471 .with_code("CONFIG-INVALID");
472 }
473 };
474 let sandbox_config = match sandbox::parse(&config_text) {
476 Ok(c) => c,
477 Err(e) => {
478 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
479 .with_code("CONFIG-INVALID");
480 }
481 };
482 let (runner, sandbox_notice) = match sandbox::runner(&wt_root, &sandbox_config) {
483 Ok(r) => r,
484 Err(e) => return refuse(CommandStatus::ConfigError, e),
485 };
486 if options.mode == speccoder_core::oracle::RunMode::Autopilot && !runner.is_sandboxed() {
488 return refuse(
489 CommandStatus::ConfigError,
490 "autopilot needs the OS sandbox, and commands here would run unsandboxed".to_owned(),
491 )
492 .with_code("SANDBOX-UNAVAILABLE");
493 }
494 let mutation_config = match mutation::parse(&config_text) {
495 Ok(c) => c,
496 Err(e) => {
497 return refuse(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
498 .with_code("CONFIG-INVALID");
499 }
500 };
501 let check_argvs = verify::check_argvs(&config_root);
502 let policy = tools::policy::Policy::new(&check_argvs, &run_config.allow)
503 .with_prefixes(run_config.allow_prefix.clone())
504 .rules(format.rules());
505 let (policy, runner, carve_notice) = match options.session.as_ref().and_then(|s| s.lock().ok())
508 {
509 Some(s) => s.confine(policy, runner),
510 None => (policy, runner, None),
511 };
512 let sandbox_notice = sandbox_notice.or(carve_notice);
513 if let Some(e) = options.session.as_ref().and_then(|s| {
516 s.lock().ok().and_then(|s| {
517 if interrupted.is_some() {
519 s.check_before_tick(&policy).err()
520 } else {
521 s.check(&policy).err()
522 }
523 })
524 }) {
525 return e.result("run");
526 }
527
528 let wt_root_canonical = std::fs::canonicalize(&wt_root)
529 .unwrap_or_else(|_| wt_root.clone())
530 .display()
531 .to_string();
532
533 let this_run = options.run();
536 let (decisions, task_grants) = match approvals::Decisions::load(&s.plane_dir) {
537 Ok(mut d) => {
538 if !autopilot
541 && d.claim(&feature_name, task, this_run.as_str())
542 && let Err(e) = d.save(&s.plane_dir)
543 {
544 return refuse(CommandStatus::Error, e);
545 }
546 (
547 d.for_task(&feature_name, task),
548 if autopilot {
549 Vec::new()
550 } else {
551 d.for_run(&feature_name, task, this_run.as_str())
552 },
553 )
554 }
555 Err(e) => return refuse(CommandStatus::ConfigError, e),
556 };
557
558 if let Some((stage, last_call)) = &resumed_from {
559 live.event(&progress::Event::Resumed {
560 task: task.to_owned(),
561 stage: *stage,
562 last_call: last_call.clone(),
563 });
564 }
565 live.event(&progress::Event::TaskStarted {
566 feature: feature_name.clone(),
567 task: task.to_owned(),
568 title: parsed_task
569 .as_ref()
570 .map(|t| t.title.clone())
571 .unwrap_or_default(),
572 });
573 let fail = |status: CommandStatus, m: String| {
575 live.event(&progress::Event::TaskFinished {
576 task: task.to_owned(),
577 outcome: "error".to_owned(),
578 message: redact(&m, secret),
579 run: None,
580 input_tokens: 0,
581 output_tokens: 0,
582 cache_read_tokens: 0,
583 cache_write_tokens: 0,
584 usd: None,
585 });
586 refuse(status, m)
587 };
588 let began = std::time::Instant::now();
590 stage(
591 &*live,
592 task,
593 Stage::Baseline,
594 StageState::Started,
595 None,
596 None,
597 );
598 let baseline = match verify::run_checks(&project, Some(verify::config::Tier::Fast), &runner) {
599 Ok(b) => b,
600 Err(e) => {
601 stage(
602 &*live,
603 task,
604 Stage::Baseline,
605 StageState::Failed,
606 None,
607 Some(began),
608 );
609 return fail(CommandStatus::ConfigError, format!("baseline: {e}"));
610 }
611 };
612 if !options.allow_failing_baseline
617 && let Some((c, why)) = baseline
618 .checks
619 .iter()
620 .find_map(|c| broken(c).map(|w| (c, w)))
621 {
622 stage(
623 &*live,
624 task,
625 Stage::Baseline,
626 StageState::Failed,
627 Some(format!("`{}` cannot run", c.name)),
628 Some(began),
629 );
630 let checkout = std::path::Path::new(&s.snapshot.root);
633 if let Some(remedy) = crate::code::setup_remedy(checkout, Some(&feature_name)) {
634 return fail(
635 CommandStatus::GitPrecondition,
636 format!(
637 "check `{}` cannot run in the worktree: your checkout has not committed its setup — {why}",
638 c.name
639 ),
640 )
641 .with_code("SETUP-INCOMPLETE")
642 .with_fix(vec!["speccoder fix".to_owned(), remedy.command.clone()])
643 .with_remedy(Some(remedy));
644 }
645 return fail(
646 CommandStatus::ConfigError,
647 if why.contains("git add") || why.contains("--allow-failing-baseline") {
650 format!(
651 "check `{}` cannot run on the untouched code, so it could never judge {task}: {why}",
652 c.name
653 )
654 } else {
655 format!(
656 "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",
657 c.name
658 )
659 },
660 )
661 .with_code("CHECK-BROKEN");
662 }
663 let failing = baseline
665 .checks
666 .iter()
667 .filter(|c| c.status.is_failure())
668 .count();
669 stage(
670 &*live,
671 task,
672 Stage::Baseline,
673 StageState::Passed,
674 (failing > 0).then(|| format!("{failing} check(s) already failing")),
675 Some(began),
676 );
677
678 let rt = match tokio_runtime() {
679 Ok(rt) => rt,
680 Err(e) => return fail(CommandStatus::RunFailed, e),
681 };
682 let bundle_value = match serde_json::to_value(&bundle) {
683 Ok(v) => v,
684 Err(e) => return fail(CommandStatus::Error, e.to_string()),
685 };
686 let instructions = crate::instructions::collect(
690 &wt_root,
691 &project,
692 &speccoder_core::scope::named_paths(&bundle.task.title),
693 &run_config.instructions,
694 );
695 let attempt_instructions: &[crate::instructions::Instruction] = if executor.is_none() {
696 &instructions
697 } else {
698 &[]
699 };
700
701 let servers = match mcp::parse(&config_text) {
703 Ok(s) => s,
704 Err(e) => {
705 return fail(CommandStatus::ConfigError, format!("speccoder.toml: {e}"))
706 .with_code("CONFIG-INVALID");
707 }
708 };
709 let mut connected = if servers.is_empty() {
710 mcp::Connected::default()
711 } else {
712 live.event(&progress::Event::Notice {
713 text: format!("starting {} MCP server(s)", servers.len()),
714 });
715 rt.block_on(mcp::connect(&servers, &project, &runner))
716 };
717 let approved_tools: Vec<String> = task_grants
719 .iter()
720 .filter_map(|g| g.tool_name().map(str::to_owned))
721 .collect();
722 let pinned = mcp::pin_reviewed(&s.plane_dir, &mut connected.tools, &approved_tools);
723 connected.notices.extend(pinned);
724
725 let tools_server = match tools::WorkspaceTools::new(&wt_root, policy.clone()) {
728 Ok(t) => std::sync::Arc::new(t.with_runner(runner.clone())),
729 Err(e) => return fail(CommandStatus::Error, e.to_string()),
730 };
731 let _ = crate::approvals::expire_due(&plane_dir, agentplane::core::Timestamp::now_utc());
733 let present = crate::approvals::PresentContext {
735 root: None,
736 sandbox: runner.is_sandboxed().then(|| {
737 if cfg!(target_os = "macos") {
738 "Seatbelt".to_owned()
739 } else {
740 "Landlock+seccomp".to_owned()
741 }
742 }),
743 network: match sandbox_config.network {
744 sandbox::Network::Off => "none",
745 sandbox::Network::Loopback => "loopback",
746 sandbox::Network::On => "unrestricted",
747 }
748 .to_owned(),
749 file_text: None,
750 };
751 let plane = match rt.block_on(async {
752 substrate::Plane::open_with(
753 &plane_dir,
754 &s.model,
755 provider,
756 Some(tools_server),
757 substrate::Hooks {
758 progress: Some(Arc::clone(&live)),
759 mcp: connected.clients.clone(),
760 mcp_tools: connected.tools.clone(),
761 format: Some(format),
762 present: present.clone(),
763 },
764 )
765 }) {
766 Ok(p) => p,
767 Err(e) => return fail(CommandStatus::RunFailed, e.0),
768 };
769 let mut notices: Vec<String> = plane
770 .replaced_journal
771 .iter()
772 .map(|p| {
773 format!(
774 "the journal could not be read by this build and was moved to {}; past runs stay in git",
775 p.display()
776 )
777 })
778 .chain(connected.notices.iter().cloned())
779 .chain(sandbox_notice.clone())
780 .chain(resumed_from.as_ref().map(|(stage, last_call)| {
781 format!(
782 "Resuming — {task} was interrupted during {}{}; finished calls will not repeat.",
783 stage.map_or("a run", speccoder_core::stage::Stage::name),
784 last_call
785 .as_ref()
786 .map(|c| format!(" ({c})"))
787 .unwrap_or_default()
788 )
789 }))
790 .collect();
791
792 let demand_red = run_config.acceptance && format.is_convergence(&bundle.task.phase);
795 let start = match &interrupted {
801 Some(_) => match git::Git::at(&wt_root).line(&["rev-parse", "HEAD^{tree}"]) {
802 Ok(tree) => worktree::Checkpoint(tree),
803 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
804 },
805 None => match worktree::checkpoint(&wt_root, &tmp) {
806 Ok(c) => c,
807 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
808 },
809 };
810
811 let began = std::time::Instant::now();
812 let bug_ctx = super::bug::Ctx {
817 rt: &rt,
818 plane: &plane,
819 project: &project,
820 wt_root: &wt_root,
821 wt_path: &wt_path,
822 tmp: &tmp,
823 policy: &policy,
824 runner: &runner,
825 state: &s.plane_dir,
826 feature: &feature_name,
827 task,
828 bundle: &bundle_value,
829 instructions: &instructions,
830 live: &*live,
831 approver: approver.as_deref(),
832 held_out: run_config.held_out,
833 max_tries: run_config.reproduce_tries,
834 bug_test: run_config.bug_test.as_deref(),
835 start: &start,
836 };
837 let mut bug_fix = match super::bug::phase(&s.plane_dir, &feature_name, task, &project, format) {
838 Err(e) => return fail(CommandStatus::NeedsHuman, e),
839 Ok(None) => None,
840 Ok(Some(super::bug::Phase::Fix(f))) => Some(*f),
841 Ok(Some(super::bug::Phase::Reproduce(b))) if interrupted.is_none() => {
842 match super::bug::reproduce(&bug_ctx, *b) {
843 Ok(f) => Some(f),
844 Err(stop) => return *stop,
845 }
846 }
847 Ok(Some(super::bug::Phase::Reproduce(_))) => None,
848 };
849 let bug_candidate = bug_fix.as_ref().and_then(|f| {
850 f.bug.candidate.clone().and_then(|c| {
851 crate::bug::state::extra(&s.plane_dir, &f.bug.unit, &f.bug.task)
852 .candidate_patch
853 .map(|d| (d, c))
854 })
855 });
856 let scenarios = match &bug_fix {
857 Some(f) => super::bug::scenarios(&f.bug),
858 None => format.scenarios(&snap, task),
859 };
860 let mut review_frozen: Option<(speccoder_core::acceptance::PreFrozenAcceptance, String)> = None;
863 let mut review_reuse = None;
864 if bug_fix.is_none() && run_config.acceptance && !options.fresh_acceptance {
865 match crate::oracle::for_task(&s.plane_dir, &project, &wt_root, format, &snap, &scenarios) {
866 Some(r @ crate::oracle::TaskReuse::Ready { .. }) => review_reuse = Some(r),
867 Some(crate::oracle::TaskReuse::Reused { req }) => notices.push(format!(
868 "{req}'s frozen tests were reused by an earlier task of this run: this task's acceptance tests are authored"
869 )),
870 Some(crate::oracle::TaskReuse::Elsewhere { req }) => {
871 notices.push(format!("{req}: frozen oracle not present here — authoring"))
872 }
873 Some(crate::oracle::TaskReuse::Lapsed {
874 req,
875 frozen,
876 current,
877 }) => {
878 if crate::oracle::note_lapse(&s.plane_dir, &feature_name, &req, &frozen) {
879 live.event(&progress::Event::Oracle {
880 step: progress::OracleEvent::Lapsed {
881 unit: feature_name.clone(),
882 requirement: req.clone(),
883 frozen_wording_sha256: frozen,
884 current_wording_sha256: current,
885 },
886 });
887 }
888 notices.push(format!(
889 "{req}'s wording changed since its tests were frozen at review: the frozen oracle lapsed — authoring"
890 ));
891 }
892 Some(crate::oracle::TaskReuse::Tampered(why)) => {
893 return fail(
894 CommandStatus::NeedsHuman,
895 format!("{why}: the task stops for a person, nothing is ticked"),
896 )
897 .with_code("ORACLE-TAMPERED");
898 }
899 Some(crate::oracle::TaskReuse::Unreadable(why)) => {
900 return fail(
901 CommandStatus::NeedsHuman,
902 format!("{why}: whether its tests are frozen is unknown, so the task stops for a person, nothing is ticked"),
903 )
904 .with_code("REVIEW-UNREADABLE");
905 }
906 None => {}
907 }
908 }
909 let mut acceptance = if let Some(prepared) = interrupted
910 .as_ref()
911 .and_then(|m| m.acceptance.clone())
912 .and_then(|a| serde_json::from_value::<PreparedAcceptance>(a).ok())
913 {
914 let mut prepared = prepared;
918 if let Some(sealed) = bug_fix
919 .as_ref()
920 .and_then(|f| f.record.held_out_sealed.as_ref())
921 {
922 prepared.held_out = Some(sealed.manifest_sha256.clone());
923 prepared.report["held_out"] = serde_json::json!({
924 "state": "on",
925 "manifest": format!("sha256:{}", sealed.manifest_sha256),
926 "kept": sealed.count,
927 });
928 }
929 Some(prepared)
930 } else if let Some(f) = &bug_fix {
931 stage(&*live, task, Stage::Red, StageState::Started, None, None);
932 match super::bug::reuse(&bug_ctx, f) {
933 Ok(a) => Some(a),
934 Err(e) => {
935 let _ = worktree::restore(&wt_root, &tmp, &start);
936 stage(
937 &*live,
938 task,
939 Stage::Red,
940 StageState::Failed,
941 Some(e.clone()),
942 Some(began),
943 );
944 return fail(CommandStatus::NeedsHuman, e);
945 }
946 }
947 } else if let Some(reuse) = review_reuse.take() {
948 stage(&*live, task, Stage::Red, StageState::Started, None, None);
951 let crate::oracle::TaskReuse::Ready {
952 record,
953 files,
954 tests,
955 manifest,
956 } = reuse
957 else {
958 return fail(CommandStatus::Error, "no frozen oracle".to_owned());
959 };
960 match reuse_pre_frozen(
961 &project,
962 &record,
963 &files,
964 &tests,
965 &runner,
966 RedRule::RunTime,
967 Some(&SealedCheck {
968 state: &s.plane_dir,
969 unit: &feature_name,
970 wt_root: &wt_root,
971 tmp: &tmp,
972 policy: &policy,
973 }),
974 ) {
975 Ok(mut a) => {
976 a.report["pre_frozen"]["manifest"] = serde_json::json!(manifest);
977 notices.push(format!(
978 "acceptance tests frozen at review by {} ({}) for {}: no author call",
979 record.decided.by, record.decided.via, record.requirement
980 ));
981 review_frozen = Some((*record, manifest));
982 Some(a)
983 }
984 Err(e) => {
985 let _ = worktree::restore(&wt_root, &tmp, &start);
986 stage(
987 &*live,
988 task,
989 Stage::Red,
990 StageState::Failed,
991 Some(e.clone()),
992 Some(began),
993 );
994 return fail(CommandStatus::NeedsHuman, e).with_code("ORACLE-TAMPERED");
995 }
996 }
997 } else if run_config.acceptance && format.acceptance_testable(&bundle) {
998 stage(&*live, task, Stage::Red, StageState::Started, None, None);
999 Some(prepare_acceptance(&AcceptanceSetup {
1000 rt: &rt,
1001 plane: &plane,
1002 project: &project,
1003 worktree: &wt_root,
1004 tmp: &tmp,
1005 policy: &policy,
1006 feature: &feature_name,
1007 task,
1008 bundle: bundle_value.clone(),
1009 scenarios: &scenarios,
1010 instructions: &instructions,
1011 runner: &runner,
1012 }))
1013 } else {
1014 None
1015 };
1016 if review_frozen.is_none()
1017 && acceptance
1018 .as_ref()
1019 .is_some_and(|a| a.report["source"] == "review")
1020 && let Some(crate::oracle::TaskReuse::Ready {
1021 record, manifest, ..
1022 }) = review_reuse.take()
1023 {
1024 review_frozen = Some((*record, manifest));
1025 }
1026 let held_out_store = match (&bug_fix, &review_frozen) {
1029 (Some(f), _) => crate::held_out::sealed_dir(&s.plane_dir, &f.bug.unit, &f.bug.requirement),
1030 (None, Some((r, _))) => {
1031 crate::held_out::sealed_dir(&s.plane_dir, &feature_name, &r.requirement)
1032 }
1033 (None, None) => crate::held_out::dir(&s.plane_dir, &feature_name, task),
1034 };
1035 if let Some(a) = acceptance
1036 .as_mut()
1037 .filter(|a| a.report.get("held_out").is_none())
1038 {
1039 if !run_config.held_out {
1040 a.report["held_out"] = serde_json::json!({ "state": "off" });
1041 } else if a.argv.is_none() {
1042 a.report["held_out"] =
1043 serde_json::json!({ "state": "absent", "reason": "no_visible_tests" });
1044 } else if !a.red {
1045 a.report["held_out"] = serde_json::json!({ "state": "absent", "reason": "not_red" });
1046 } else if scenarios.is_empty() {
1047 a.report["held_out"] =
1048 serde_json::json!({ "state": "absent", "reason": "no_scenarios" });
1049 } else {
1050 let (held, sha) = prepare_held_out(
1051 &AcceptanceSetup {
1052 rt: &rt,
1053 plane: &plane,
1054 project: &project,
1055 worktree: &wt_root,
1056 tmp: &tmp,
1057 policy: &policy,
1058 feature: &feature_name,
1059 task,
1060 bundle: bundle_value.clone(),
1061 scenarios: &scenarios,
1062 instructions: &instructions,
1063 runner: &runner,
1064 },
1065 a,
1066 &scenarios,
1067 &held_out_store,
1068 story_checkpoint(
1069 snap.feature.as_ref().and_then(|f| f.tasks.as_ref()),
1070 task,
1071 &scenarios,
1072 ),
1073 );
1074 a.report["held_out"] = held;
1075 a.held_out = sha;
1076 }
1077 }
1078 if run_config.held_out && !run_config.acceptance {
1079 notices.push(
1080 "[acceptance] held_out has no effect while generate = false: no held-out tests"
1081 .to_owned(),
1082 );
1083 }
1084 match &acceptance {
1085 None => stage(
1086 &*live,
1087 task,
1088 Stage::Red,
1089 StageState::Skipped,
1090 Some("no acceptance examples".to_owned()),
1091 None,
1092 ),
1093 Some(a) if a.red => stage(
1094 &*live,
1095 task,
1096 Stage::Red,
1097 StageState::Passed,
1098 a.red_class.map(class_word),
1099 Some(began),
1100 ),
1101 Some(a) => stage(
1102 &*live,
1103 task,
1104 Stage::Red,
1105 StageState::Failed,
1106 Some(a.red_class.map_or_else(
1107 || "not demonstrated".to_owned(),
1108 |c| format!("not demonstrated ({})", class_word(c)),
1109 )),
1110 Some(began),
1111 ),
1112 }
1113
1114 if let Some(why) = acceptance
1118 .as_ref()
1119 .and_then(|a| a.generation_failed.clone())
1120 {
1121 let reason = format!(
1122 "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"
1123 );
1124 let _ = worktree::restore(&wt_root, &tmp, &start);
1125 let report = RunReport {
1126 reason: Some(reason.clone()),
1127 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1128 set_aside,
1129 notices: notices.clone(),
1130 ..bare_report(&feature_name, task, &wt_path, "acceptance_failed")
1131 };
1132 live.event(&finished_event(
1133 task,
1134 &report,
1135 "acceptance_failed",
1136 false,
1137 redact(&reason, secret),
1138 ));
1139 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1140 .with_code("ACCEPTANCE-UNWRITTEN")
1141 .with_data(CommandData::Run(Box::new(report)));
1142 }
1143
1144 if demand_red && !acceptance.as_ref().is_some_and(|a| a.red) {
1145 let why = undemonstrated_why(acceptance.as_ref());
1146 let reason = format!(
1147 "convergence gap not demonstrated: {why}; a person decides whether {task} is real"
1148 );
1149 let saved = attempts::save(
1151 &wt_root,
1152 &tmp,
1153 &feature_name,
1154 task,
1155 &attempts::Record {
1156 run: None,
1157 outcome: "not_demonstrated",
1158 reason: &reason,
1159 pending: None,
1160 usage: None,
1161 },
1162 )
1163 .ok()
1164 .map(|s| s.reference);
1165 let _ = worktree::restore(&wt_root, &tmp, &start);
1166 let report = RunReport {
1167 reason: Some(reason.clone()),
1168 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1169 saved_attempt: saved,
1170 set_aside,
1171 notices: notices.clone(),
1172 ..bare_report(&feature_name, task, &wt_path, "not_demonstrated")
1173 };
1174 live.event(&finished_event(
1175 task,
1176 &report,
1177 "not_demonstrated",
1178 false,
1179 redact(&reason, secret),
1180 ));
1181 return CommandResult::new("run", CommandStatus::NeedsHuman, &redact(&reason, secret))
1182 .with_data(CommandData::Run(Box::new(report)));
1183 }
1184
1185 let before = match &interrupted {
1188 Some(m) => worktree::Checkpoint(m.before.clone()),
1189 None => match worktree::checkpoint(&wt_root, &tmp) {
1190 Ok(c) => c,
1191 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1192 },
1193 };
1194 let acceptance_saved = acceptance
1195 .as_ref()
1196 .and_then(|a| serde_json::to_value(a).ok());
1197 let frozen: Vec<String> = worktree::files(&wt_root, &before)
1199 .unwrap_or_default()
1200 .into_iter()
1201 .filter(|p| tools::paths::is_test_path(p))
1202 .collect();
1203 let mut usage: Option<serde_json::Value> = None;
1204 let mut samples: Vec<(RunReport, String, worktree::Checkpoint, serde_json::Value)> = Vec::new();
1207 let mut held_out_log: Vec<serde_json::Value> = Vec::new();
1209 let mut bug_weak: Option<speccoder_core::strength::Permitted> = None;
1211 let (mut report, attempt_status) = loop {
1212 let mut earlier: Vec<speccoder_core::EarlierAttempt> = Vec::new();
1213 let mut digest: Option<serde_json::Value> = None;
1214 let mut attempt_no = interrupted
1217 .as_ref()
1218 .filter(|m| m.sample == samples.len())
1219 .map_or(1, |m| m.attempt.max(1));
1220 let (mut report, attempt_status) = loop {
1221 let hidden: Vec<String> = options
1223 .session
1224 .as_ref()
1225 .and_then(|s| s.lock().ok().map(|s| s.hidden.clone()))
1226 .unwrap_or_default();
1227 let (task_grants, approved_once) = match approvals::Decisions::load(&s.plane_dir) {
1233 Ok(_) if autopilot => (Vec::new(), Vec::new()),
1236 Ok(mut d) => {
1237 let once = d.take_once(&feature_name, task, this_run.as_str());
1238 if !once.is_empty()
1239 && let Err(e) = d.save(&s.plane_dir)
1240 {
1241 return fail(CommandStatus::Error, e);
1242 }
1243 (d.for_run(&feature_name, task, this_run.as_str()), once)
1244 }
1245 Err(e) => return fail(CommandStatus::ConfigError, e),
1246 };
1247 let run_approved: Vec<crate::tools::policy::Object> = options
1249 .run_grants
1250 .lock()
1251 .map(|g| g.clone())
1252 .unwrap_or_default();
1253 let input = substrate::attempt::input(
1255 bundle_value.clone(),
1256 &substrate::attempt::Settings {
1257 checks: &check_argvs,
1258 allow: &run_config.allow,
1259 allow_prefix: &run_config.allow_prefix,
1260 max_turns: run_config.max_turns,
1261 max_tool_calls: run_config.max_tool_calls,
1262 reasoning: s.model.reasoning_summary,
1263 frozen: &frozen,
1264 approved: &task_grants,
1265 rejected: &decisions.rejected,
1266 run_approved: &run_approved,
1267 approved_once: &approved_once,
1268 nudges: &decisions.nudges,
1269 mcp: &connected.tools,
1270 root: &wt_root_canonical,
1271 frozen_additions: run_config.frozen_additions,
1272 asks: if options.lane.is_some() {
1275 "stop"
1276 } else {
1277 "tasks"
1278 },
1279 wait_secs: run_config.approval_wait.as_secs(),
1280 network_allow: &sandbox_config.network_allow,
1281 network_allow_prefix: &sandbox_config.network_allow_prefix,
1282 run_id: this_run.as_str(),
1283 asked: options.asked_now(),
1284 budget: run_config.approval_budget,
1285 prompts: approver.is_some() && options.lane.is_none(),
1286 in_place: options.session.is_some(),
1287 hidden: &hidden,
1288 autopilot: options.mode == speccoder_core::oracle::RunMode::Autopilot,
1289 },
1290 digest.as_ref(),
1291 attempt_instructions,
1292 );
1293 let attempt_began = std::time::Instant::now();
1294 let attempt_detail = if run_config.best_of > 1 {
1295 format!(
1296 "{attempt_no}/{} · sample {}/{}",
1297 run_config.max_attempts,
1298 samples.len() + 1,
1299 run_config.best_of
1300 )
1301 } else {
1302 format!("{attempt_no}/{}", run_config.max_attempts)
1303 };
1304 stage(
1305 &*live,
1306 task,
1307 Stage::Attempt,
1308 StageState::Started,
1309 Some(attempt_detail.clone()),
1310 None,
1311 );
1312 let judged_before = integrity::JudgeSnapshot::take(&wt_root, &policy);
1315 let (run_id, succeeded, mut output) = if let Some((diff, cand)) = &bug_candidate {
1318 let held: Vec<String> = bug_fix
1319 .as_ref()
1320 .and_then(|f| {
1321 f.bug
1322 .held_out_sealed
1323 .clone()
1324 .map(|h| (f.bug.unit.clone(), h))
1325 })
1326 .and_then(|(unit, h)| {
1327 crate::held_out::open_sealed(&s.plane_dir, &unit, &h).ok()
1328 })
1329 .map(|(m, _)| m.files.into_iter().map(|f| f.path).collect())
1330 .unwrap_or_default();
1331 match super::bug::apply_candidate(&bug_ctx, diff, cand, &frozen, &held) {
1332 Ok(x) => x,
1333 Err(stop) => return *stop,
1334 }
1335 } else if let Some(exec) = &executor {
1336 let allow: Vec<Vec<String>> = check_argvs
1337 .iter()
1338 .chain(&run_config.allow)
1339 .cloned()
1340 .collect();
1341 let prompt = external::prompt_in(format.wording(), &bundle_value, digest.as_ref());
1342 let withheld = acceptance
1343 .as_ref()
1344 .and_then(|a| a.held_out.as_ref())
1345 .map(|_| held_out_store.as_path());
1346 if withheld.is_some()
1347 && !notices
1348 .iter()
1349 .any(|n| n.starts_with("held-out set withheld"))
1350 {
1351 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());
1352 }
1353 let o = external::run(exec, &wt_root, &prompt, &allow, &tmp, withheld);
1354 (o.run_id, true, o.output)
1355 } else {
1356 let key = match &interrupted {
1358 Some(m) if m.attempt == attempt_no && m.sample == samples.len() => {
1359 m.key.clone()
1360 }
1361 _ => resume::new_key(&feature_name, task),
1362 };
1363 let _ = resume::save(
1364 &marker_path,
1365 &resume::Marker {
1366 key: key.clone(),
1367 before: before.0.clone(),
1368 head: head_now.clone(),
1369 attempt: attempt_no,
1370 sample: samples.len(),
1371 acceptance: acceptance_saved.clone(),
1372 run_id: Some(this_run.to_string()),
1373 asked: options.asked_now(),
1374 run_grants: run_approved.clone(),
1375 },
1376 );
1377 marker_guard.arm();
1378 let outcome = match rt.block_on(plane.attempt_keyed(
1379 &feature_name,
1380 task,
1381 input,
1382 &key,
1383 )) {
1384 Ok((o, resumed)) => {
1385 if resumed {
1386 notices.push(format!(
1387 "resumed run {} of an interrupted process: finished model and tool calls were read back from the journal, not repeated",
1388 o.run_id
1389 ));
1390 }
1391 o
1392 }
1393 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1394 };
1395 let context = crate::approvals::PresentContext {
1399 root: Some(wt_root.clone()),
1400 ..present.clone()
1401 };
1402 let (outcome, waiting) = match rt.block_on(plane.decide_inline(
1403 outcome,
1404 approver.as_deref(),
1405 &context,
1406 )) {
1407 Ok(x) => {
1408 notices.extend(crate::actions::allow_always_inline(
1411 path,
1412 &feature_name,
1413 &x.always,
1414 state,
1415 ));
1416 (x.outcome, x.waiting)
1417 }
1418 Err(e) => return fail(CommandStatus::RunFailed, e.0),
1419 };
1420 if let Some(task_row) = waiting {
1421 marker_guard.keep();
1424 let action = &task_row.justification.proposed_action;
1425 let mut report = bare_report(&feature_name, task, &wt_path, "needs_approval");
1426 report.run_id = outcome.run_id.to_string();
1427 report.notices = notices;
1428 report.tool_calls = action["log"].as_array().cloned().unwrap_or_default();
1429 report.tool_calls.push(serde_json::json!({
1431 "tool": action["tool"],
1432 "arguments": action["arguments"],
1433 "decision": "approval",
1434 "reason": action["request"]["reason"],
1435 "object": action["request"]["object"],
1436 "waiting": true,
1437 "call": task_row.id.to_hex(),
1438 }));
1439 report.pending = Some(serde_json::json!({
1440 "tool": action["tool"],
1441 "arguments": action["arguments"],
1442 "reason": action["request"]["reason"],
1443 "object": action["request"]["object"],
1444 "request": action["request"],
1445 "call": task_row.id.to_hex(),
1446 "deadline": task_row.due_at.map(|t| t.unix_timestamp()),
1447 "waiting": true,
1448 "batched": action["request"]["batched"],
1449 }));
1450 if let (Some(n), Ok(mut asked)) = (
1453 action["request"]["asked"]
1454 .as_u64()
1455 .and_then(|n| u32::try_from(n).ok()),
1456 options.asked.lock(),
1457 ) {
1458 *asked = (*asked).max(n);
1459 }
1460 report.reason = Some(format!(
1461 "waiting for a person: {} — `speccoder task approve {task}` or `speccoder task reject {task} --reason …`, then `speccoder run`",
1462 task_row.justification.summary.peek()
1463 ));
1464 let (status, message) = status_and_message(&report, "needs_approval", false);
1465 live.event(&finished_event(
1466 task,
1467 &report,
1468 "needs_approval",
1469 false,
1470 redact(&message, secret),
1471 ));
1472 return CommandResult::new("run", status, &redact(&message, secret))
1473 .with_data(CommandData::Run(Box::new(report)));
1474 }
1475 let succeeded = matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded);
1476 let mut output = outcome
1477 .output
1478 .as_ref()
1479 .map(|o| o.peek().clone())
1480 .unwrap_or(serde_json::Value::Null);
1481 if !succeeded && output["reason"].as_str().is_none() {
1482 if !output.is_object() {
1483 output = serde_json::json!({});
1484 }
1485 output["reason"] =
1486 serde_json::Value::String(redact(&format!("{:?}", outcome.status), secret));
1487 }
1488 (outcome.run_id.to_string(), succeeded, output)
1489 };
1490 if bug_candidate.is_some()
1491 && let Some(c) = bug_fix.as_mut().and_then(|f| f.bug.candidate.as_mut())
1492 {
1493 c.applied = true;
1494 }
1495 if let Some(argv) = run_config.format.as_ref().filter(|_| {
1498 succeeded && matches!(output["status"].as_str(), Some("completed" | "incomplete"))
1499 }) {
1500 use verify::process::CommandRunner as _;
1501 let ran = runner.run(&verify::process::Invocation {
1502 argv: argv.clone(),
1503 cwd: project.clone(),
1504 env: verify::process::scrubbed_env(std::env::vars()),
1505 timeout: std::time::Duration::from_secs(300),
1506 output_cap: verify::process::OUTPUT_CAP,
1507 });
1508 if ran.exit_code != Some(0) {
1509 notices.push(format!(
1510 "the formatter (`{}`) did not succeed",
1511 argv.join(" ")
1512 ));
1513 }
1514 }
1515 let after = match worktree::checkpoint(&wt_root, &tmp) {
1516 Ok(c) => c,
1517 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1518 };
1519 let raw_changes = worktree::diff(&wt_root, &before, &after).unwrap_or_default();
1520 if let (Some(n), Ok(mut asked)) = (
1525 output["asked"].as_u64().and_then(|n| u32::try_from(n).ok()),
1526 options.asked.lock(),
1527 ) {
1528 *asked = n;
1529 }
1530 let approved_writes = approved_writes(&task_grants, &output["tool_calls"]);
1531 let findings = attempt_findings(&Audited {
1532 wt_root: &wt_root,
1533 policy: &policy,
1534 judged_before: &judged_before,
1535 raw_changes: &raw_changes,
1536 patch: &worktree::patch(&wt_root, &before, &after).unwrap_or_default(),
1537 frozen: &frozen,
1538 frozen_additions: run_config.frozen_additions,
1539 approved_writes: &approved_writes,
1540 command_drift: &output["integrity"],
1541 });
1542 let changes = changes_of(raw_changes);
1543
1544 let quarantined = !succeeded
1549 && output["reason"]
1550 .as_str()
1551 .is_some_and(|r| r.starts_with("Quarantined("));
1552 if quarantined {
1553 output["reason"] = serde_json::Value::String(
1554 "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(),
1555 );
1556 }
1557 let mut attempt_status = if succeeded {
1558 output["status"].as_str().unwrap_or("failed").to_owned()
1559 } else if quarantined {
1560 "interrupted".to_owned()
1561 } else {
1562 "failed".to_owned()
1563 };
1564 let mut report = RunReport {
1565 run_id: run_id.clone(),
1566 model_report: output.get("report").cloned(),
1567 reason: output["reason"].as_str().map(str::to_owned),
1568 pending: output.get("pending").cloned(),
1569 tool_calls: output["tool_calls"].as_array().cloned().unwrap_or_default(),
1570 changes,
1571 usage: output.get("usage").cloned(),
1572 integrity: findings.clone(),
1573 acceptance: acceptance.as_ref().map(|a| a.report.clone()),
1574 set_aside: set_aside.clone(),
1575 notices: notices.clone(),
1576 ..bare_report(&feature_name, task, &wt_path, &attempt_status)
1577 };
1578 if let Err(e) = record_inline_approvals(
1579 &s.plane_dir,
1580 &feature_name,
1581 task,
1582 &report.tool_calls,
1583 &options.run_grants,
1584 this_run.as_str(),
1585 ) {
1586 report.notices.push(format!(
1587 "the decisions made in this attempt were not recorded ({e}): later attempts ask again"
1588 ));
1589 }
1590 for c in report
1592 .tool_calls
1593 .iter()
1594 .filter(|c| c["approved_by"] == "person")
1595 {
1596 if let Some(name) = c["tool"].as_str().filter(|n| n.starts_with(mcp::PREFIX)) {
1597 mcp::mark_reviewed(&s.plane_dir, &connected.tools, name);
1598 }
1599 }
1600
1601 stage(
1602 &*live,
1603 task,
1604 Stage::Attempt,
1605 if attempt_status == "completed" {
1606 StageState::Passed
1607 } else {
1608 StageState::Failed
1609 },
1610 Some(if attempt_status == "completed" {
1611 attempt_detail
1612 } else {
1613 format!("{attempt_detail} {attempt_status}")
1614 }),
1615 Some(attempt_began),
1616 );
1617 if attempt_status == "integrity" {
1620 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1621 reasons: findings.clone(),
1622 });
1623 }
1624 let judged = matches!(attempt_status.as_str(), "completed" | "incomplete");
1626 if judged {
1627 let began = std::time::Instant::now();
1628 stage(&*live, task, Stage::Verify, StageState::Started, None, None);
1629 if let Err(e) = judge_checks(
1630 &project,
1631 &runner,
1632 &baseline,
1633 acceptance.as_ref(),
1634 &findings,
1635 &mut report,
1636 ) {
1637 stage(
1638 &*live,
1639 task,
1640 Stage::Verify,
1641 StageState::Failed,
1642 None,
1643 Some(began),
1644 );
1645 return fail(CommandStatus::ConfigError, format!("verification: {e}"));
1646 }
1647 let checks_held = report.verification.as_ref().is_some_and(|v| v.passed);
1648 stage_with_tail(
1649 &*live,
1650 task,
1651 Stage::Verify,
1652 if checks_held {
1653 StageState::Passed
1654 } else {
1655 StageState::Failed
1656 },
1657 None,
1658 Some(began),
1659 report
1660 .verification
1661 .as_ref()
1662 .map(failing_tail)
1663 .unwrap_or_default(),
1664 );
1665 }
1666 if judged || attempt_status == "integrity" {
1667 stage(
1668 &*live,
1669 task,
1670 Stage::Audit,
1671 if findings.is_empty() {
1672 StageState::Passed
1673 } else {
1674 StageState::Failed
1675 },
1676 findings
1677 .first()
1678 .map(|f| f.trim_start_matches("integrity: ").to_owned()),
1679 None,
1680 );
1681 }
1682
1683 let visibly_passed = judged
1687 && findings.is_empty()
1688 && attempt_status == "completed"
1689 && matches!(
1690 report.verdict,
1691 Some(
1692 speccoder_core::verify::Verdict::Verified
1693 | speccoder_core::verify::Verdict::RegressionOnly
1694 )
1695 );
1696 if let Some(sha) = acceptance
1697 .as_ref()
1698 .and_then(|a| a.held_out.clone())
1699 .filter(|_| visibly_passed)
1700 {
1701 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1702 let mut known = format!(
1703 "{}\n{}\n{}",
1704 bundle_value["story"].as_str().unwrap_or_default(),
1705 bundle_value["plan"].as_str().unwrap_or_default(),
1706 bundle_value["constitution"].as_str().unwrap_or_default()
1707 );
1708 for p in report
1709 .acceptance
1710 .as_ref()
1711 .and_then(|a| a["files"].as_array().cloned())
1712 .unwrap_or_default()
1713 {
1714 if let Some(p) = p.as_str() {
1715 known.push_str(
1716 &std::fs::read_to_string(project.join(p)).unwrap_or_default(),
1717 );
1718 }
1719 }
1720 match judge_held_out(
1721 &held_out_store,
1722 &sha,
1723 &scenarios,
1724 &known,
1725 &wt_root,
1726 &project,
1727 &tmp,
1728 &before,
1729 &patch,
1730 &runner,
1731 &policy,
1732 ) {
1733 Ok(j) => {
1734 let failed_ids: Vec<String> =
1735 j.failed.iter().map(|s| s.id.clone()).collect();
1736 held_out_log.push(serde_json::json!({
1737 "attempt": held_out_log.len() + 1,
1738 "outcome": if failed_ids.is_empty() { "passed" } else { "failed" },
1739 "scenarios": failed_ids,
1740 }));
1741 if let Some(v) = report.verdict.take() {
1742 let mut v = speccoder_core::verify::with_held_out(v, &j.held_out);
1743 if let speccoder_core::verify::Verdict::Failed { reasons } = &mut v
1744 && !j.failed.is_empty()
1745 && let Some(last) = reasons.last_mut()
1746 {
1747 *last = speccoder_core::retry::held_out_reason(&j.failed);
1748 }
1749 report.verdict = Some(v);
1750 }
1751 }
1752 Err((outcome, why)) => {
1753 held_out_log.push(serde_json::json!({
1754 "attempt": held_out_log.len() + 1,
1755 "outcome": outcome,
1756 }));
1757 report.verdict = None;
1758 report.reason = Some(why);
1759 attempt_status = "held_out".to_owned();
1760 }
1761 }
1762 }
1763 if let Some(fix) = bug_fix.as_mut().filter(|_| {
1766 attempt_status == "completed"
1767 && findings.is_empty()
1768 && matches!(
1769 report.verdict,
1770 Some(
1771 speccoder_core::verify::Verdict::Verified
1772 | speccoder_core::verify::Verdict::RegressionOnly
1773 )
1774 )
1775 }) {
1776 let keep = crate::strength::dir(&s.plane_dir, &feature_name, task);
1777 let judging = super::bug::Judging {
1778 before: &before,
1779 baseline: &baseline,
1780 verification: report.verification.as_ref(),
1781 base: report
1782 .verdict
1783 .clone()
1784 .unwrap_or(speccoder_core::verify::Verdict::RegressionOnly),
1785 keep: &keep,
1786 };
1787 match super::bug::judge(&bug_ctx, fix, &judging) {
1788 Ok(j) => {
1789 report.verdict = Some(j.verdict);
1790 if let Some(weak) = j.weak {
1791 report.reason = Some(format!(
1792 "reproduction too weak: a fix that special-cases {}'s input passes ({}); `speccoder task rule {task} --tests-right --reason …` if the tests are right",
1793 weak.scenario, weak.rationale
1794 ));
1795 bug_weak = Some(weak);
1796 attempt_status = "repro_weak".to_owned();
1797 }
1798 }
1799 Err(e) => {
1800 report.integrity.push(e.clone());
1801 report.reason = Some(e);
1802 report.verdict = None;
1803 attempt_status = "integrity".to_owned();
1804 }
1805 }
1806 }
1807 if let Some(h) = report
1808 .acceptance
1809 .as_mut()
1810 .and_then(|a| a.get_mut("held_out"))
1811 .filter(|h| h["state"] == "on")
1812 {
1813 let failed = held_out_log
1814 .iter()
1815 .filter(|o| o["outcome"] == "failed")
1816 .count();
1817 h["attempts"] = serde_json::json!(held_out_log);
1818 h["gap"] = serde_json::json!(failed > 0);
1819 }
1820
1821 let passing = attempt_status == "completed"
1823 && matches!(
1824 report.verdict,
1825 Some(
1826 speccoder_core::verify::Verdict::Verified
1827 | speccoder_core::verify::Verdict::RegressionOnly
1828 )
1829 );
1830 if passing && mutation_config.as_ref().is_none_or(|m| !m.suite) {
1831 stage(
1832 &*live,
1833 task,
1834 Stage::Mutation,
1835 StageState::Skipped,
1836 Some(if mutation_config.is_none() {
1837 "not configured".to_owned()
1838 } else {
1839 "suite = false: for acceptance strength only".to_owned()
1840 }),
1841 None,
1842 );
1843 }
1844 if let Some(m) = mutation_config.as_ref().filter(|m| passing && m.suite) {
1845 let began = std::time::Instant::now();
1846 stage(
1847 &*live,
1848 task,
1849 Stage::Mutation,
1850 StageState::Started,
1851 None,
1852 None,
1853 );
1854 let patch = worktree::patch(&wt_root, &before, &after).unwrap_or_default();
1855 let scratch = runner.scratch().unwrap_or_else(|| tmp.clone());
1856 if let Some(result) = mutation::run(m, &project, &patch, &scratch, &runner) {
1857 if m.mode == mutation::Mode::Require
1858 && let Some(reason) = mutation::failure(&result)
1859 {
1860 report.verdict = Some(speccoder_core::verify::Verdict::Failed {
1861 reasons: vec![reason],
1862 });
1863 }
1864 report.mutation = Some(result);
1865 }
1866 let held = !matches!(
1867 report.verdict,
1868 Some(speccoder_core::verify::Verdict::Failed { .. })
1869 );
1870 stage(
1871 &*live,
1872 task,
1873 Stage::Mutation,
1874 if held {
1875 StageState::Passed
1876 } else {
1877 StageState::Failed
1878 },
1879 report
1880 .mutation
1881 .as_ref()
1882 .and_then(crate::mutation::trailer)
1883 .map(|t| t.trim().trim_start_matches("Mutation: ").to_owned()),
1884 Some(began),
1885 );
1886 }
1887
1888 usage = add_usage(usage.as_ref(), report.usage.as_ref());
1889 let (Some(speccoder_core::verify::Verdict::Failed { reasons }), Some(verification)) =
1891 (report.verdict.clone(), report.verification.as_ref())
1892 else {
1893 break (report, attempt_status);
1894 };
1895 if attempt_status != "completed" {
1896 break (report, attempt_status);
1897 }
1898 let discarded: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
1899 let d = speccoder_core::retry::digest(attempt_no, &reasons, verification, &discarded);
1900 let fingerprint = speccoder_core::retry::fingerprint(&d);
1901 let stop = if bug_candidate.is_some() {
1902 Some("a candidate is judged, never retried".to_owned())
1903 } else {
1904 retry_stop(
1905 &findings,
1906 verification,
1907 earlier.last().map(|e| e.fingerprint.as_str()),
1908 &fingerprint,
1909 attempt_no,
1910 run_config.max_attempts,
1911 )
1912 };
1913 if let Some(stop) = stop {
1914 report.retry_stop = Some(stop);
1915 break (report, attempt_status);
1916 }
1917 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
1918 report.retry_stop = Some(format!("cannot undo the attempt: {}", e.0));
1919 break (report, attempt_status);
1920 }
1921 earlier.push(speccoder_core::EarlierAttempt {
1922 run_id: report.run_id.clone(),
1923 attempt_status: attempt_status.clone(),
1924 verdict: report.verdict.clone(),
1925 fingerprint,
1926 digest: d.clone(),
1927 usage: report.usage.clone(),
1928 });
1929 digest = serde_json::to_value(&d).ok();
1930 attempt_no += 1;
1931 };
1932 report.earlier = earlier;
1933 if run_config.best_of <= 1 {
1934 break (report, attempt_status);
1935 }
1936 let end = match worktree::checkpoint(&wt_root, &tmp) {
1937 Ok(c) => c,
1938 Err(e) => return fail(CommandStatus::GitPrecondition, e.0),
1939 };
1940 let patch = worktree::patch(&wt_root, &before, &end).unwrap_or_default();
1941 let summary = sample_summary(&report, &attempt_status, &patch);
1942 samples.push((report, attempt_status, end, summary));
1943 if samples.len() >= run_config.best_of as usize {
1944 let best = best_sample(&samples);
1945 let summaries: Vec<serde_json::Value> = samples
1946 .iter()
1947 .enumerate()
1948 .map(|(i, s)| {
1949 let mut v = s.3.clone();
1950 v["kept"] = serde_json::json!(i == best);
1951 v
1952 })
1953 .collect();
1954 let (mut chosen, status, end, _) = samples.swap_remove(best);
1955 if best != samples.len() {
1956 if let Err(e) = worktree::restore(&wt_root, &tmp, &end) {
1958 return fail(CommandStatus::GitPrecondition, e.0);
1959 }
1960 }
1961 chosen.samples = summaries;
1962 break (chosen, status);
1963 }
1964 if let Err(e) = worktree::restore(&wt_root, &tmp, &before) {
1965 return fail(CommandStatus::GitPrecondition, e.0);
1966 }
1967 };
1968 report.usage = usage;
1969 report.scope = parsed_task.as_ref().map(|t| scope_of(&report, &t.title));
1971
1972 let strength_dir = crate::strength::dir(&s.plane_dir, &feature_name, task);
1976 let mut attempt_status = attempt_status;
1977 let verified = attempt_status == "completed"
1978 && matches!(
1979 report.verdict,
1980 Some(speccoder_core::verify::Verdict::Verified)
1981 );
1982 let visible_argv = acceptance
1983 .as_ref()
1984 .filter(|a| a.red)
1985 .and_then(|a| a.argv.clone());
1986 if verified && !run_config.strength.enabled {
1987 stage(
1988 &*live,
1989 task,
1990 Stage::Strength,
1991 StageState::Skipped,
1992 Some("strength = false".to_owned()),
1993 None,
1994 );
1995 }
1996 if let Some(argv) = visible_argv.filter(|_| verified && run_config.strength.enabled) {
1997 use speccoder_core::strength::{Gate, gate, over_strict};
1998 let began = std::time::Instant::now();
1999 stage(
2000 &*live,
2001 task,
2002 Stage::Strength,
2003 StageState::Started,
2004 None,
2005 None,
2006 );
2007 let fin = worktree::checkpoint(&wt_root, &tmp);
2008 let patch = fin
2009 .as_ref()
2010 .ok()
2011 .and_then(|now| worktree::patch(&wt_root, &start, now).ok())
2012 .unwrap_or_default();
2013 let mut notes = Vec::new();
2014 let held: Vec<(crate::held_out::HeldOutFile, String)> =
2015 match acceptance.as_ref().and_then(|a| a.held_out.clone()) {
2016 None => Vec::new(),
2017 Some(sha) => match crate::held_out::load_verified(&held_out_store, &sha) {
2018 Ok((manifest, contents)) => manifest
2019 .files
2020 .into_iter()
2021 .zip(contents.into_iter().map(|(_, c)| c))
2022 .collect(),
2023 Err(t) => {
2024 notes.push(format!("held-out set not overlaid: {}", t.0));
2025 Vec::new()
2026 }
2027 },
2028 };
2029 let rulings = crate::strength::rulings(&strength_dir);
2030 let mut commands = vec![argv];
2031 let measure = |commands: &[Vec<String>]| {
2032 crate::strength::measure(
2033 &crate::strength::Setup {
2034 wt_root: &wt_root,
2035 project: &project,
2036 tmp: &tmp,
2037 runner: &runner,
2038 policy: &policy,
2039 acceptance: commands,
2040 held_out: &held,
2041 mutation: mutation_config.as_ref(),
2042 patch: &patch,
2043 config: &run_config.strength,
2044 keep: &strength_dir,
2045 rulings: &rulings,
2046 },
2047 |changed| {
2048 let input = substrate::near_miss::input(
2050 &bundle_value,
2051 changed,
2052 &crate::mutation::source_patch(&patch),
2053 run_config.strength.near_miss,
2054 run_config.strength.permitted,
2055 );
2056 match rt.block_on(plane.near_miss(&feature_name, task, input)) {
2057 Ok(o) => {
2058 let out = o
2059 .output
2060 .as_ref()
2061 .map(|v| v.peek().clone())
2062 .unwrap_or_default();
2063 let ok = matches!(o.status, agentplane::prelude::RunStatus::Succeeded)
2064 && !out["answer"].is_null();
2065 crate::strength::Authored {
2066 answer: if ok {
2067 Ok(out["answer"].clone())
2068 } else {
2069 Err(format!(
2070 "the variant author did not answer ({})",
2071 o.status.as_str()
2072 ))
2073 },
2074 run_id: Some(o.run_id.to_string()),
2075 usage: Some(out["usage"].clone()).filter(|u| !u.is_null()),
2076 }
2077 }
2078 Err(e) => crate::strength::Authored {
2079 answer: Err(redact(&e.0, secret)),
2080 run_id: None,
2081 usage: None,
2082 },
2083 }
2084 },
2085 )
2086 };
2087 let mut measured = measure(&commands);
2088 let min = run_config.strength.min;
2091 if let (Ok(a), Ok(fin)) = (&measured, &fin)
2092 && gate(min, &a.combined, false) == Gate::Failed
2093 && over_strict(&a.permitted).is_empty()
2094 {
2095 let visible_tests: Vec<(String, String)> = report
2096 .acceptance
2097 .as_ref()
2098 .and_then(|a| a["files"].as_array().cloned())
2099 .unwrap_or_default()
2100 .iter()
2101 .filter_map(|p| p.as_str())
2102 .map(|p| {
2103 (
2104 p.to_owned(),
2105 std::fs::read_to_string(project.join(p)).unwrap_or_default(),
2106 )
2107 })
2108 .collect();
2109 let setup = AcceptanceSetup {
2110 rt: &rt,
2111 plane: &plane,
2112 project: &project,
2113 worktree: &wt_root,
2114 tmp: &tmp,
2115 policy: &policy,
2116 feature: &feature_name,
2117 task,
2118 bundle: bundle_value.clone(),
2119 scenarios: &scenarios,
2120 instructions: &instructions,
2121 runner: &runner,
2122 };
2123 let weak = crate::strength::weak(a);
2124 match strengthen(&setup, &visible_tests, weak, &before, fin) {
2125 Ok(added) => {
2126 if let Some(acc) = report.acceptance.as_mut() {
2127 if let Some(files) = acc["files"].as_array_mut() {
2128 files.extend(added.files.iter().map(|f| serde_json::json!(f)));
2129 }
2130 acc["strengthened"] = serde_json::json!({
2131 "files": added.files,
2132 "argv": added.argv,
2133 "run_id": added.report["run_id"],
2134 "usage": added.report["usage"],
2135 });
2136 }
2137 report.usage = add_usage(report.usage.as_ref(), Some(&added.report["usage"]));
2138 commands.push(added.argv);
2139 let mut again = measure(&commands);
2140 if let Ok(b) = again.as_mut() {
2141 b.gate = gate(min, &b.combined, true);
2142 b.notes.insert(
2143 0,
2144 "strengthened once with added acceptance tests".to_owned(),
2145 );
2146 }
2147 measured = again;
2148 }
2149 Err(why) => {
2150 if let Ok(a) = measured.as_mut() {
2151 a.notes
2152 .insert(0, format!("could not strengthen the set: {why}"));
2153 }
2154 }
2155 }
2156 }
2157 match measured {
2158 Ok(mut a) => {
2159 if a.gate == Gate::Off {
2160 a.gate = gate(min, &a.combined, false);
2161 }
2162 a.notes.extend(notes);
2163 let findings = over_strict(&a.permitted);
2164 if !findings.is_empty() {
2165 attempt_status = "over_strict".to_owned();
2166 report.reason = Some(format!(
2167 "an acceptance test rejects an implementation the spec appears to allow: {}",
2168 findings
2169 .iter()
2170 .map(|p| format!("{} ({})", p.scenario, p.rationale))
2171 .collect::<Vec<_>>()
2172 .join("; ")
2173 ));
2174 } else if a.gate == Gate::Failed {
2175 attempt_status = "acceptance_weak".to_owned();
2176 report.reason = Some(format!(
2177 "acceptance set too weak: strength {} is below min_strength {}{}",
2178 speccoder_core::strength::summary(&a.combined, None),
2179 min.unwrap_or_default(),
2180 a.notes
2181 .first()
2182 .map(|n| format!(" ({n})"))
2183 .unwrap_or_default()
2184 ));
2185 } else {
2186 crate::strength::release(&strength_dir);
2187 }
2188 report.usage = add_usage(report.usage.as_ref(), a.usage.as_ref());
2190 report.strength = Some(a);
2191 }
2192 Err(e) => {
2193 report.integrity.push(e.clone());
2194 report.reason = Some(e);
2195 report.verdict = None;
2196 attempt_status = "integrity".to_owned();
2197 }
2198 }
2199 report.attempt_status.clone_from(&attempt_status);
2200 let held_up = attempt_status == "completed";
2201 stage(
2202 &*live,
2203 task,
2204 Stage::Strength,
2205 if held_up {
2206 StageState::Passed
2207 } else {
2208 StageState::Failed
2209 },
2210 report.strength.as_ref().map(|a| {
2211 speccoder_core::strength::summary(&a.combined, a.notes.first().map(String::as_str))
2212 }),
2213 Some(began),
2214 );
2215 }
2216
2217 let verdict_passed = attempt_status == "completed"
2221 && matches!(
2222 report.verdict,
2223 Some(
2224 speccoder_core::verify::Verdict::Verified
2225 | speccoder_core::verify::Verdict::RegressionOnly
2226 )
2227 );
2228 let mut suspected = false;
2229 if !run_config.monitor && verdict_passed {
2230 stage(
2231 &*live,
2232 task,
2233 Stage::Monitor,
2234 StageState::Skipped,
2235 Some("not enabled".to_owned()),
2236 None,
2237 );
2238 }
2239 if run_config.monitor && verdict_passed {
2240 let began = std::time::Instant::now();
2241 stage(
2242 &*live,
2243 task,
2244 Stage::Monitor,
2245 StageState::Started,
2246 None,
2247 None,
2248 );
2249 let patch = worktree::checkpoint(&wt_root, &tmp)
2250 .and_then(|now| worktree::patch(&wt_root, &start, &now))
2251 .unwrap_or_default();
2252 let calls: Vec<serde_json::Value> = report
2253 .tool_calls
2254 .iter()
2255 .map(|c| {
2256 let tool = c["tool"].as_str().unwrap_or_default();
2257 serde_json::json!({ "tool": tool, "target": progress::target(tool, &c["arguments"]), "decision": c["decision"] })
2258 })
2259 .collect();
2260 let input = substrate::monitor::input(&bundle_value, &patch, &calls, &frozen);
2261 let (monitor, suspicion) = monitor_attempt(&rt, &plane, &feature_name, task, input, secret);
2262 report.monitor = Some(monitor);
2263 suspected = suspicion;
2264 stage(
2265 &*live,
2266 task,
2267 Stage::Monitor,
2268 if suspected {
2269 StageState::Failed
2270 } else {
2271 StageState::Passed
2272 },
2273 suspected.then(|| "suspects gaming".to_owned()),
2274 Some(began),
2275 );
2276 }
2277
2278 let in_place_stop = options
2281 .session
2282 .as_ref()
2283 .filter(|_| verdict_passed && !suspected)
2284 .and_then(|s| {
2285 s.lock()
2286 .ok()
2287 .and_then(|s| s.check_before_tick(&policy).err())
2288 });
2289 if let Some(e) = &in_place_stop {
2290 report.reason = Some(e.line());
2291 report.verdict = None;
2292 attempt_status = e.code.to_lowercase().replace('-', "_");
2294 report.attempt_status.clone_from(&attempt_status);
2295 }
2296 let passed = verdict_passed && !suspected && in_place_stop.is_none();
2297 if passed && let (Some(t), Some(file)) = (&parsed_task, &tasks_file) {
2298 let began = std::time::Instant::now();
2299 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
2300 let message = tick_message(&TickFacts {
2301 feature: &feature_name,
2302 task,
2303 title: &t.title,
2304 report: &report,
2305 red: acceptance
2306 .as_ref()
2307 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
2308 checklists_overridden,
2309 extra: bug_fix
2310 .as_ref()
2311 .map(|f| {
2312 super::bug::trailers(
2313 f,
2314 &report
2315 .verdict
2316 .as_ref()
2317 .map(super::outcome::verdict_word)
2318 .unwrap_or_default(),
2319 &crate::strength::rulings(&strength_dir),
2320 )
2321 })
2322 .or_else(|| review_frozen.as_ref().map(|(r, m)| r.trailer(m)))
2324 .unwrap_or_default(),
2325 });
2326 match &options.session {
2327 None => {
2329 match format.tick(file, task, &t.line_hash) {
2330 Ok(()) => report.ticked = true,
2331 Err(e) => report.integrity.push(format!("tick refused: {e}")),
2332 }
2333 if report.ticked && run_config.commit_per_task {
2334 match commit_attempt(&wt_root, &message) {
2335 Ok(id) => report.commit = Some(id),
2336 Err(e) => report.integrity.push(format!("commit failed: {e}")),
2337 }
2338 }
2339 }
2340 Some(session) => {
2343 let digest =
2344 crate::in_place::intent_digest(&project, &policy, format.messages().tasks_file);
2345 if let Ok(mut s) = session.lock() {
2346 let done = s.commit_and_tick(
2347 &plane_dir,
2348 file,
2349 &t.line_hash,
2350 &message,
2351 &digest,
2352 || format.tick(file, task, &t.line_hash),
2353 );
2354 report.ticked = done.ticked;
2355 report.commit = done.commit;
2356 report.integrity.extend(done.problems);
2357 }
2358 }
2359 }
2360 report.instructions = attempt_instructions
2361 .iter()
2362 .map(|i| speccoder_core::InstructionFile {
2363 path: i.path.clone(),
2364 sha256: i.sha256.clone(),
2365 bytes: i.bytes,
2366 cut: i.cut,
2367 })
2368 .collect();
2369 if let Some(commit) = report
2371 .commit
2372 .clone()
2373 .filter(|_| report.ticked && run_config.attest.enabled)
2374 {
2375 let verdict = report
2376 .verdict
2377 .as_ref()
2378 .map(super::outcome::verdict_word)
2379 .unwrap_or_default();
2380 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
2381 let feature_dir = snap
2382 .feature
2383 .as_ref()
2384 .map(|f| f.feature.dir.clone())
2385 .unwrap_or_default();
2386 let requirements = format.requirement_ids(Path::new(&snap.root), &feature_dir);
2387 let tick = crate::attest::Tick {
2388 repo: &wt_root,
2389 config: &config_text,
2390 attest: &run_config.attest,
2391 format: &format!("{:?}", format.kind()).to_lowercase(),
2392 feature: &feature_name,
2393 feature_dir: &feature_dir,
2394 task,
2395 title: &t.title,
2396 line_hash: &t.line_hash,
2397 section: t.story.as_deref(),
2398 section_text: bundle_value["story"].as_str().unwrap_or_default(),
2399 requirements: &requirements,
2400 commit: &commit,
2401 verdict: &verdict,
2402 sandbox: serde_json::json!({
2403 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
2404 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
2405 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
2406 }),
2407 executor: serde_json::json!({
2408 "kind": if bug_candidate.is_some() { "candidate".to_owned() } else { executor.as_ref().map_or_else(|| "native".to_owned(), |e| match &e.kind {
2409 external::Kind::Claude => "claude".to_owned(),
2410 external::Kind::Codex => "codex".to_owned(),
2411 external::Kind::Command(_) => "command".to_owned(),
2412 }) },
2413 "candidate": bug_candidate.as_ref().map(|(_, c)| serde_json::json!({ "origin": c.origin, "digest": c.digest, "head_sha": c.head_sha })),
2414 }),
2415 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
2416 bug: bug_fix
2417 .as_ref()
2418 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
2419 };
2420 report.attestation = Some(crate::attest::record_tick(&tick, &report));
2421 }
2422 let committed = report.ticked && (report.commit.is_some() || !run_config.commit_per_task);
2423 stage(
2424 &*live,
2425 task,
2426 Stage::Commit,
2427 if committed {
2428 StageState::Passed
2429 } else {
2430 StageState::Failed
2431 },
2432 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
2433 Some(began),
2434 );
2435 }
2436 if passed && let Some(n) = &native {
2439 let began = std::time::Instant::now();
2440 stage(&*live, task, Stage::Commit, StageState::Started, None, None);
2441 let verdict = report
2442 .verdict
2443 .as_ref()
2444 .map(super::outcome::verdict_word)
2445 .unwrap_or_default();
2446 let message = tick_message(&TickFacts {
2447 feature: &feature_name,
2448 task,
2449 title: &n.title,
2450 report: &report,
2451 red: acceptance
2452 .as_ref()
2453 .and_then(|a| a.red_class.filter(|c| a.red && c.is_red())),
2454 checklists_overridden: false,
2455 extra: bug_fix
2456 .as_ref()
2457 .map(|f| {
2458 super::bug::trailers(f, &verdict, &crate::strength::rulings(&strength_dir))
2459 })
2460 .unwrap_or_default(),
2461 });
2462 match commit_attempt(&wt_root, &message) {
2463 Ok(id) => report.commit = Some(id),
2464 Err(e) => report.integrity.push(format!("commit failed: {e}")),
2465 }
2466 if let Some(commit) = report.commit.clone().filter(|_| run_config.attest.enabled) {
2467 let sandboxed = sandbox_config.mode != sandbox::Mode::Off && sandbox::backend().is_ok();
2468 let tick = crate::attest::Tick {
2469 repo: &wt_root,
2470 config: &config_text,
2471 attest: &run_config.attest,
2472 format: &format!("{:?}", format.kind()).to_lowercase(),
2473 feature: &feature_name,
2474 feature_dir: &n.dir,
2475 task,
2476 title: &n.title,
2477 line_hash: "",
2478 section: None,
2479 section_text: &n.story,
2480 requirements: &[],
2481 commit: &commit,
2482 verdict: &verdict,
2483 sandbox: serde_json::json!({
2484 "mode": format!("{:?}", sandbox_config.mode).to_lowercase(),
2485 "backend": sandboxed.then_some(if cfg!(target_os = "macos") { "seatbelt" } else { "landlock" }),
2486 "network": format!("{:?}", sandbox_config.network).to_lowercase(),
2487 }),
2488 executor: serde_json::json!({
2489 "kind": if bug_candidate.is_some() { "candidate" } else if executor.is_some() { "external" } else { "native" },
2490 }),
2491 model: serde_json::json!({ "provider": s.model.provider, "model": s.model.model }),
2492 bug: bug_fix
2493 .as_ref()
2494 .map(|f| super::bug::predicate_fields(&f.bug, &f.record)),
2495 };
2496 report.attestation = Some(crate::attest::record_tick(&tick, &report));
2497 }
2498 stage(
2499 &*live,
2500 task,
2501 Stage::Commit,
2502 if report.commit.is_some() {
2503 StageState::Passed
2504 } else {
2505 StageState::Failed
2506 },
2507 report.commit.as_ref().map(|c| c.chars().take(7).collect()),
2508 Some(began),
2509 );
2510 }
2511
2512 if attempt_status == "repro_weak"
2517 && let Some(weak) = &bug_weak
2518 && let Err(e) = crate::strength::hold(&strength_dir, &[weak])
2519 {
2520 report
2521 .integrity
2522 .push(format!("holding the attempt failed: {e}"));
2523 }
2524 if attempt_status == "over_strict" || attempt_status == "repro_weak" {
2525 let findings = report
2526 .strength
2527 .as_ref()
2528 .map(|a| speccoder_core::strength::over_strict(&a.permitted))
2529 .unwrap_or_default();
2530 if attempt_status == "over_strict"
2531 && let Err(e) = crate::strength::hold(&strength_dir, &findings)
2532 {
2533 report
2534 .integrity
2535 .push(format!("holding the attempt failed: {e}"));
2536 }
2537 marker_guard.keep();
2538 }
2539 if !passed && attempt_status != "over_strict" && attempt_status != "repro_weak" {
2542 let (outcome, reason) = saved_outcome(&report, &attempt_status, suspected);
2543 let run = (!report.run_id.is_empty()).then_some(report.run_id.as_str());
2544 let reason = redact(&reason, secret);
2545 let record = attempts::Record {
2546 run,
2547 outcome: &outcome,
2548 reason: &reason,
2549 pending: report.pending.as_ref(),
2550 usage: report.usage.as_ref(),
2551 };
2552 match attempts::save(&wt_root, &tmp, &feature_name, task, &record)
2553 .and_then(|saved| worktree::restore(&wt_root, &tmp, &start).map(|_| saved))
2554 {
2555 Ok(saved) => report.saved_attempt = Some(saved.reference),
2556 Err(e) => report
2557 .integrity
2558 .push(format!("saving the attempt failed: {}", e.0)),
2559 }
2560 }
2561
2562 let (status, message) = status_and_message(&report, &attempt_status, suspected);
2563 if let Some(fix) = bug_fix.as_mut() {
2564 return super::bug::finished(
2565 &bug_ctx,
2566 fix,
2567 report,
2568 &attempt_status,
2569 passed,
2570 status,
2571 redact(&message, secret),
2572 );
2573 }
2574 live.event(&finished_event(
2575 task,
2576 &report,
2577 &attempt_status,
2578 suspected,
2579 redact(&message, secret),
2580 ));
2581 CommandResult::new("run", status, &redact(&message, secret))
2582 .with_data(CommandData::Run(Box::new(report)))
2583}
2584
2585fn with_cause(note: &str, c: &speccoder_core::verify::CheckResult) -> String {
2588 let said = c
2589 .stderr_tail
2590 .lines()
2591 .chain(c.stdout_tail.lines())
2592 .map(str::trim)
2593 .find(|l| l.starts_with("error"))
2594 .map(|l| l.chars().take(200).collect::<String>());
2595 let fix = match said.as_deref() {
2596 Some(l) if l.contains("profile `speccoder` not found") => Some(
2597 "`.config/nextest.toml` is not committed — `git add .config/nextest.toml && git commit -m \"speccoder: nextest profile\"`",
2598 ),
2599 Some(l) if l.contains("no tests to run") => Some(
2600 "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",
2601 ),
2602 Some(l) if l.contains("must be present") || l.contains("no targets") => Some(
2603 "the project has nothing to build yet; if the first task creates it, run with --allow-failing-baseline",
2604 ),
2605 _ => None,
2606 };
2607 match (said, fix) {
2608 (Some(s), Some(f)) => format!("{note} ({s}); {f}"),
2609 (Some(s), None) => format!("{note} ({s})"),
2610 _ => note.to_owned(),
2611 }
2612}
2613
2614#[must_use]
2618pub fn broken(c: &speccoder_core::verify::CheckResult) -> Option<String> {
2619 use speccoder_core::verify::CheckStatus;
2620 if !c.status.is_failure() || c.status == CheckStatus::TimedOut {
2621 return None;
2622 }
2623 if let Some(note) = &c.note {
2624 return Some(with_cause(note, c));
2625 }
2626 if c.tests.as_ref().is_some_and(|t| !t.is_empty()) {
2627 return None;
2628 }
2629 let summary = crate::verify::summary::parse(&c.stdout_tail, &c.stderr_tail, c.exit_code);
2630 let runner = c
2634 .argv
2635 .first()
2636 .map(|a| a.rsplit('/').next().unwrap_or(a))
2637 .is_some_and(|a| {
2638 matches!(
2639 a,
2640 "cargo"
2641 | "pytest"
2642 | "python"
2643 | "python3"
2644 | "uv"
2645 | "npm"
2646 | "npx"
2647 | "pnpm"
2648 | "yarn"
2649 | "bun"
2650 | "go"
2651 | "jest"
2652 | "vitest"
2653 | "mvn"
2654 | "gradle"
2655 | "dotnet"
2656 | "mix"
2657 | "swift"
2658 )
2659 });
2660 let cannot_start = matches!(c.exit_code, Some(126 | 127));
2661 let is_broken = match &summary {
2662 _ if cannot_start => true,
2663 Some(s) if s.build_failed => true,
2664 Some(s) => s.passed + s.failed + s.errors == 0,
2665 None => runner,
2666 };
2667 match is_broken {
2668 false => None,
2669 true => {
2670 let first = c
2671 .stderr_tail
2672 .lines()
2673 .chain(c.stdout_tail.lines())
2674 .map(str::trim)
2675 .find(|l| l.to_lowercase().contains("error"))
2676 .or_else(|| c.stderr_tail.lines().map(str::trim).find(|l| !l.is_empty()))
2677 .unwrap_or("it failed without running a test")
2678 .chars()
2679 .take(160)
2680 .collect::<String>();
2681 Some(first)
2682 }
2683 }
2684}