1use std::path::Path;
7
8use speccoder_core::actions::{Action, Runnable};
9use speccoder_core::{
10 ApprovalsReport, CommandData, CommandResult, CommandStatus, InboxItem, InboxReport,
11 StatusReport,
12};
13
14use crate::actions::action;
15
16pub const KINDS: [&str; Kind::ALL.len()] = {
20 let mut out = [""; Kind::ALL.len()];
21 let mut i = 0;
22 while i < out.len() {
23 out[i] = Kind::ALL[i].as_str();
24 i += 1;
25 }
26 out
27};
28
29use speccoder_core::inbox::Kind;
30
31#[must_use]
34pub fn inbox(path: &Path, feature: Option<&str>) -> CommandResult {
35 inbox_with(path, feature, crate::worktree::state_dir().as_deref())
36}
37
38#[must_use]
41pub fn inbox_with(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
42 let listed = crate::current::list(path, None);
43 let Some(CommandData::Features(f)) = listed.data() else {
44 return CommandResult::carried("inbox", &listed);
45 };
46 let only = match feature {
48 Some(_) => match crate::current::list(path, feature).data() {
49 Some(CommandData::Features(one)) => one.current.clone(),
50 _ => None,
51 },
52 None => None,
53 };
54 if feature.is_some() && only.is_none() {
55 return CommandResult::carried("inbox", &crate::status::report(path, feature));
56 }
57 let names: Vec<String> = f
58 .features
59 .iter()
60 .map(|e| e.name.clone())
61 .filter(|n| only.as_ref().is_none_or(|o| o == n))
62 .collect();
63 let several = names.len() > 1;
64 let mut items = Vec::new();
65 for name in &names {
66 items.extend(of_feature(path, name, state, several));
67 }
68 if only.is_none()
70 && let Some(s) = state
71 && let Some(project) = crate::format::at(path).detect(path)
72 {
73 let plane = crate::worktree::project_state_dir(s, &project);
74 items.extend(
75 crate::bug::state::all(&plane)
76 .iter()
77 .filter(|b| b.home.kind.native())
78 .filter(|b| !crate::controls::skipped(&project, &b.unit).contains_key(&b.task))
80 .filter_map(|b| bug_item(b, &|c| c)),
81 );
82 }
83 sort(&mut items);
84 let working = working(path, state);
85 let idle: Vec<String> = names
86 .iter()
87 .filter(|n| !items.iter().any(|i| &i.feature == *n))
88 .filter(|n| !working.iter().any(|w| &w.feature == *n))
89 .cloned()
90 .collect();
91 let groups = speccoder_core::inbox::Group::ALL
92 .iter()
93 .map(|g| speccoder_core::GroupCount {
94 group: g.as_str().to_owned(),
95 title: g.title().to_owned(),
96 count: match g {
97 speccoder_core::inbox::Group::Working => working.len(),
98 speccoder_core::inbox::Group::Idle => idle.len(),
99 _ => items.iter().filter(|i| i.group == g.as_str()).count(),
100 },
101 })
102 .collect();
103 let message = summary(&items, names.len());
104 CommandResult::new("inbox", CommandStatus::Ok, &message).with_data(CommandData::Inbox(
105 Box::new(InboxReport {
106 items,
107 features: names.len(),
108 groups,
109 working,
110 idle,
111 }),
112 ))
113}
114
115fn sort(items: &mut [InboxItem]) {
119 let rank = |k: &str| KINDS.iter().position(|x| *x == k).unwrap_or(KINDS.len());
120 let blocked = |i: &InboxItem| i.group != "ready_to_review";
121 items.sort_by(|a, b| {
122 let by_group = blocked(b).cmp(&blocked(a));
123 let within = if blocked(a) {
124 (a.kind != "unknown")
125 .cmp(&(b.kind != "unknown"))
126 .then(
127 a.since
128 .unwrap_or(u64::MAX)
129 .cmp(&b.since.unwrap_or(u64::MAX)),
130 )
131 .then(rank(&a.kind).cmp(&rank(&b.kind)))
132 } else {
133 review_rank(a)
134 .cmp(&review_rank(b))
135 .then(rank(&a.kind).cmp(&rank(&b.kind)))
136 .then(
137 a.since
138 .unwrap_or(u64::MAX)
139 .cmp(&b.since.unwrap_or(u64::MAX)),
140 )
141 };
142 by_group
143 .then(within)
144 .then(a.feature.cmp(&b.feature))
145 .then(a.task.cmp(&b.task))
146 });
147}
148
149fn working(path: &Path, state: Option<&Path>) -> Vec<speccoder_core::WorkingRun> {
152 let Some((live, running)) = state.and_then(|s| crate::progress::read_live(path, s)) else {
153 return Vec::new();
154 };
155 if live.finished.is_some() {
156 return Vec::new();
157 }
158 vec![speccoder_core::WorkingRun {
159 feature: live.feature,
160 task: (!live.task.is_empty()).then_some(live.task),
161 started: live.started_at,
162 pid: live.pid,
163 state: if running { "running" } else { "unknown" }.to_owned(),
164 }]
165}
166
167fn count(n: usize, one: &str, many: &str) -> String {
169 format!("{n} {}", if n == 1 { one } else { many })
170}
171
172fn counted(kind: &str, n: usize) -> String {
174 let (one, many) = match kind {
175 "unknown" => (
176 "feature whose waiting calls could not be read",
177 "features whose waiting calls could not be read",
178 ),
179 "approval" => ("approval", "approvals"),
180 "approval_batch" => (
181 "run whose calls wait together",
182 "runs whose calls wait together",
183 ),
184 "ruling" => ("ruling", "rulings"),
185 "gaming" => ("suspected shortcut", "suspected shortcuts"),
186 "unverified" => (
187 "feature little was verified in",
188 "features little was verified in",
189 ),
190 "stale" => (
191 "tick the spec changed under",
192 "ticks the spec changed under",
193 ),
194 "question" => ("open question", "open questions"),
195 "ambiguous" => ("scenario read two ways", "scenarios read two ways"),
196 "autopilot_denied" => ("call autopilot refused", "calls autopilot refused"),
197 "review" => ("spec to review", "specs to review"),
198 "revise" => ("change request", "change requests"),
199 "stuck" => ("task that did not pass", "tasks that did not pass"),
200 "yours" => ("step only you can take", "steps only you can take"),
201 "aside" => ("set-aside change", "set-aside changes"),
202 "commit" => ("plan to commit", "plans to commit"),
203 "handoff" => ("branch to hand off", "branches to hand off"),
204 "promote" => ("change that grew", "changes that grew"),
205 "bug_confirm" => (
206 "bug reproduction to confirm",
207 "bug reproductions to confirm",
208 ),
209 "bug_cannot_reproduce" => (
210 "bug that could not be reproduced",
211 "bugs that could not be reproduced",
212 ),
213 "bug_unchanged_not_holding" => (
214 "bug whose Unchanged statement does not hold",
215 "bugs whose Unchanged statements do not hold",
216 ),
217 "bug_repro_weak" => ("bug reproduction too weak", "bug reproductions too weak"),
218 "bug_already_satisfied" => ("bug already fixed", "bugs already fixed"),
219 k => (k, k),
220 };
221 count(n, one, many)
222}
223
224fn summary(items: &[InboxItem], features: usize) -> String {
226 if items.is_empty() {
227 return format!(
228 "nothing waits for you in {}: the agents can work",
229 count(features, "feature", "features")
230 );
231 }
232 let mut in_features: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
233 in_features.sort_unstable();
234 in_features.dedup();
235 let parts: Vec<String> = KINDS
236 .iter()
237 .filter_map(|k| {
238 let n = items.iter().filter(|i| i.kind == *k).count();
239 (n > 0).then(|| counted(k, n))
240 })
241 .collect();
242 format!(
243 "{} waiting in {}: {}",
244 count(items.len(), "decision", "decisions"),
245 count(in_features.len(), "feature", "features"),
246 parts.join(", ")
247 )
248}
249
250fn of_feature(path: &Path, feature: &str, state: Option<&Path>, several: bool) -> Vec<InboxItem> {
252 let report = crate::status::report(path, Some(feature));
253 let Some(CommandData::Report(r)) = report.data() else {
254 return Vec::new();
255 };
256 let approvals: Option<ApprovalsReport> =
257 state.and_then(
258 |s| match crate::approvals::list_with(path, Some(feature), s).data() {
259 Some(CommandData::Approvals(a)) => Some((**a).clone()),
260 _ => None,
261 },
262 );
263 let flag = |command: String| {
264 if several {
265 format!("{command} --feature {feature}")
266 } else {
267 command
268 }
269 };
270 let item = |kind: &str, task: Option<&str>, title: String, detail: String, actions| InboxItem {
271 kind: kind.to_owned(),
272 group: Kind::named(kind)
273 .map_or("blocked_on_you", |k| k.group().as_str())
274 .to_owned(),
275 feature: feature.to_owned(),
276 task: task.map(str::to_owned),
277 title,
278 detail,
279 since: None,
280 actions,
281 unverified: None,
282 risk: Vec::new(),
283 tool: None,
284 };
285 let mut out = Vec::new();
286 let waiting: Vec<&str> = approvals
287 .iter()
288 .flat_map(|a| &a.pending)
289 .map(|p| p.task.as_str())
290 .collect();
291
292 if let Some(why) = approvals.as_ref().and_then(|a| a.unreadable.as_ref()) {
295 out.push(item(
296 "unknown",
297 None,
298 format!("Calls waiting in {feature} could not be read"),
299 format!("{why} — some may be waiting for you"),
300 Vec::new(),
301 ));
302 }
303
304 let batched_runs: Vec<String> = approvals
307 .iter()
308 .flat_map(|a| &a.batch)
309 .map(|b| b.run.clone())
310 .collect();
311 for b in approvals.iter().flat_map(|a| &a.batch) {
312 let calls: Vec<String> = b.groups.iter().flat_map(|g| g.calls.clone()).collect();
313 let of_run: Vec<&speccoder_core::PendingCall> = approvals
314 .iter()
315 .flat_map(|a| &a.pending)
316 .filter(|p| p.run.as_deref() == Some(b.run.as_str()))
317 .collect();
318 let detail: Vec<String> = b
319 .groups
320 .iter()
321 .map(|g| {
322 let shown: Vec<String> = of_run
323 .iter()
324 .filter(|p| p.object.as_ref().map_or("write", |o| o.class.as_str()) == g.class)
325 .map(|p| {
326 let what = p
327 .object
328 .as_ref()
329 .map_or(p.covers.clone(), |o| o.short.clone());
330 let risk = p.presentation.as_ref().map_or("", |v| v.risk.as_str());
331 let copied = if p.sources.is_empty() {
332 ""
333 } else {
334 ", copied from content"
335 };
336 format!("{what} ({}, {risk}{copied})", p.task)
337 })
338 .collect();
339 format!("{}: {}", g.class, shown.join("; "))
340 })
341 .collect();
342 let decide = |verdict, resume_asking| Runnable::DecideBatch {
343 run: b.run.clone(),
344 verdict,
345 calls: calls.clone(),
346 digest: b.digest.clone(),
347 resume_asking,
348 };
349 let command = |verb: &str| {
350 flag(format!(
351 "speccoder approvals batch {} {verb} --digest {}",
352 b.run, b.digest
353 ))
354 };
355 let yes = action(
356 "next",
357 format!("approve the {} waiting call(s) once", calls.len()),
358 "each listed call runs once; the run keeps waiting at its next call",
359 command("approve"),
360 )
361 .with_run(decide(
362 speccoder_core::actions::BatchVerdict::Approve,
363 false,
364 ));
365 let again = action(
366 "next",
367 "approve once and resume asking",
368 "each listed call runs once, and the run prompts you again",
369 format!("{} --resume-asking", command("approve")),
370 )
371 .with_run(decide(speccoder_core::actions::BatchVerdict::Approve, true));
372 let mut no = action(
373 "next",
374 format!("reject the {} waiting call(s)", calls.len()),
375 "the model is told your reason for each",
376 format!("{} --reason \"…\"", command("reject")),
377 )
378 .with_run(decide(speccoder_core::actions::BatchVerdict::Reject, false));
379 no.input = Some("Why (the model is told)".to_owned());
380 let mut i = item(
381 "approval_batch",
382 None,
383 format!(
384 "{} call(s) wait for you in run {} (its approval budget is used)",
385 calls.len(),
386 b.run
387 ),
388 detail.join(" · "),
389 vec![yes, again, no],
390 );
391 i.since = of_run.iter().filter_map(|p| p.waiting_since).min();
392 out.push(i);
393 }
394
395 for p in approvals
397 .iter()
398 .flat_map(|a| &a.pending)
399 .filter(|p| p.run.as_ref().is_none_or(|r| !batched_runs.contains(r)))
400 {
401 let question = p.kind == "question";
402 let mut yes = action(
403 "next",
404 if question {
405 format!("answer {}", p.task)
406 } else {
407 format!("approve {}", p.task)
408 },
409 if question {
410 "the attempt resumes with your answer".to_owned()
411 } else {
412 format!("allow {} for the task", p.covers)
413 },
414 flag(format!("speccoder task approve {}", p.task)),
415 );
416 yes = yes.with_run(Runnable::Approve {
417 task: p.task.clone(),
418 call: p.id(),
419 digest: p.digest.clone(),
420 });
421 if question {
422 yes.input = Some("Your answer".to_owned());
423 }
424 let mut no = action(
425 "next",
426 format!("reject {}", p.task),
427 "the model is told your reason",
428 flag(format!("speccoder task reject {} --reason \"…\"", p.task)),
429 );
430 no = no.with_run(Runnable::Reject {
431 task: p.task.clone(),
432 call: p.id(),
433 digest: p.digest.clone(),
434 });
435 no.input = Some("Why (the model is told)".to_owned());
436 let mut i = item(
437 "approval",
438 Some(&p.task),
439 if question {
440 format!("{} asks you", p.task)
441 } else {
442 format!("{} waits for your approval", p.task)
443 },
444 p.question
445 .clone()
446 .filter(|_| question)
447 .unwrap_or_else(|| format!("{} — {}", p.covers, p.reason)),
448 vec![yes, no],
449 );
450 i.since = p.waiting_since;
451 i.tool = (!question).then(|| p.tool.clone());
452 out.push(i);
453 }
454
455 let root = Path::new(path);
456 let project_state = state.and_then(|s| {
457 crate::format::at(root)
458 .detect(root)
459 .map(|project| crate::worktree::project_state_dir(s, &project))
460 });
461 for t in &r.tasks {
462 let done = matches!(
463 t.state.as_str(),
464 "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
465 );
466 if done || waiting.contains(&t.id.as_str()) {
467 continue;
468 }
469 if let Some(i) = project_state
470 .as_deref()
471 .and_then(|s| crate::bug::state::load(s, feature, &t.id))
472 .and_then(|b| bug_item(&b, &flag))
473 {
474 out.push(i);
475 continue;
476 }
477 let held = project_state
478 .as_deref()
479 .and_then(|s| crate::strength::held(&crate::strength::dir(s, feature, &t.id)));
480 if let Some(held) = held {
481 let mut rule = action(
482 "task",
483 format!("rule on {}", t.id),
484 "the tests are right: the flagged alternatives are not what the spec allows",
485 flag(format!(
486 "speccoder task rule {} --tests-right --reason \"…\"",
487 t.id
488 )),
489 );
490 rule = rule.with_run(Runnable::Rule { task: t.id.clone() });
491 rule.input = Some("Why the tests are right".to_owned());
492 let findings: Vec<String> = held
493 .findings
494 .iter()
495 .map(|p| format!("{} ({})", p.scenario, p.rationale))
496 .collect();
497 out.push(item(
498 "ruling",
499 Some(&t.id),
500 format!("{}'s tests reject an alternative the spec may allow", t.id),
501 findings.join("; "),
502 vec![rule, nudge(t, &flag), skip(t, &flag)],
503 ));
504 continue;
505 }
506 let mut rulings: Vec<_> = t
510 .last_attempt
511 .iter()
512 .flat_map(|a| a.story_open.iter())
513 .map(|id| {
514 let mut a = action(
515 "decide",
516 format!("rule {id}'s tests wrong"),
517 format!("when {id}'s tests assert what the spec does not say: the next run writes them again"),
518 flag(format!(
519 "speccoder task rule {} --tests-wrong --scenario {id} --reason \"…\"",
520 t.id
521 )),
522 )
523 .with_run(Runnable::RuleScenario {
524 task: t.id.clone(),
525 scenario: Some(id.clone()),
526 });
527 a.input = Some(format!("What {id}'s tests assert that the spec does not say"));
528 a
529 })
530 .collect();
531 if let Some(path) = t.last_attempt.as_ref().and_then(|a| a.own_test.as_ref()) {
533 let mut a = action(
534 "decide",
535 "these tests are wrong".to_owned(),
536 format!("when `{path}` asserts what the spec does not say: the next run writes it again, your reason told to its author"),
537 flag(format!(
538 "speccoder task rule {} --tests-wrong --reason \"…\"",
539 t.id
540 )),
541 )
542 .with_run(Runnable::RuleScenario {
543 task: t.id.clone(),
544 scenario: None,
545 });
546 a.input = Some(format!("What `{path}` asserts that the spec does not say"));
547 rulings.push(a);
548 }
549 if t.lane == speccoder_core::lane::Lane::Yours
552 && t.last_attempt
553 .as_ref()
554 .map_or(t.state == "ready", |a| a.outcome == "blocked")
555 {
556 let (title, detail) = match &t.last_attempt {
557 Some(a) => (
558 format!("{} needs you: the agent stopped", t.id),
559 a.reason.clone(),
560 ),
561 None => (format!("{} is yours: {}", t.id, t.title), String::new()),
563 };
564 out.push(item(
565 "yours",
566 Some(&t.id),
567 title,
568 detail,
569 rulings
570 .clone()
571 .into_iter()
572 .chain(yours_actions(
573 t,
574 &dependents(&r.tasks, &t.id),
575 retry_blocked(r, &waiting),
576 &flag,
577 ))
578 .collect(),
579 ));
580 continue;
581 }
582 let Some(a) = &t.last_attempt else {
583 continue;
584 };
585 if a.outcome == "suspected_gaming" {
586 out.push(item(
587 "gaming",
588 Some(&t.id),
589 format!("{}: the monitor suspects a shortcut", t.id),
590 a.reason.clone(),
591 vec![nudge(t, &flag), skip(t, &flag), accept(t, &flag)],
592 ));
593 continue;
594 }
595 let mut actions: Vec<_> = rulings;
596 if t.state == "ready" {
597 let mut again = action(
598 "work",
599 format!("run {} again", t.id),
600 "with what the last attempt left: its facts and your notes",
601 flag(format!("speccoder run --task {}", t.id)),
602 );
603 again = again.with_run(Runnable::Run {
604 task: Some(t.id.clone()),
605 agent: None,
606 cap: None,
607 });
608 again.blocked = retry_blocked(r, &waiting);
609 let other = try_with(&t.id, a, &flag, crate::acp::agent::on_path);
610 actions.push(again);
611 if let Some(mut other) = other {
612 other.blocked = retry_blocked(r, &waiting);
613 actions.push(other);
614 }
615 }
616 actions.extend([nudge(t, &flag), skip(t, &flag), accept(t, &flag)]);
617 out.push(item(
618 "stuck",
619 Some(&t.id),
620 format!("{}'s last attempt: {}", t.id, a.outcome.replace('_', " ")),
621 match &a.own_test {
622 Some(path) => format!("Its own test `{path}` failed. {}", a.reason),
623 None => a.reason.clone(),
624 },
625 actions,
626 ));
627 }
628
629 for t in &r.tasks {
631 let Some(a) = &t.set_aside else { continue };
632 let n = a.files.len();
633 let mut keep = action(
634 "task",
635 format!("keep the changes set aside before {}", t.id),
636 "committed onto the feature's branch as yours",
637 flag(format!("speccoder task restore {}", t.id)),
638 );
639 keep = keep.with_run(Runnable::KeepSetAside { task: t.id.clone() });
640 let mut drop = action(
641 "task",
642 format!("discard the changes set aside before {}", t.id),
643 "deleted; the branch stays as it is",
644 flag(format!("speccoder task drop {}", t.id)),
645 );
646 drop = drop.with_run(Runnable::DropSetAside { task: t.id.clone() });
647 drop.confirm = Some(format!(
648 "Discard the {n} changed file(s) set aside before {}? They are deleted, not kept anywhere.",
649 t.id
650 ));
651 out.push(item(
652 "aside",
653 Some(&t.id),
654 format!("Changes were set aside before {} ran", t.id),
655 format!(
656 "{n} file(s) were changed in the worktree when the run started — your edits, or an interrupted run's: {}",
657 a.files.iter().take(4).cloned().collect::<Vec<_>>().join(", ")
658 + if n > 4 { ", …" } else { "" }
659 ),
660 vec![keep, drop],
661 ));
662 }
663
664 if let Some(state) = state {
667 let plane = crate::worktree::project_state_dir(state, path);
668 for o in crate::ladder::offers(path, &plane, feature) {
669 out.push(promote_item(&plane, &o, |t, d, a| {
670 item("promote", Some(&o.task), t, d, a)
671 }));
672 }
673 }
674
675 let stale: Vec<String> = r
676 .tasks
677 .iter()
678 .filter_map(|t| {
679 let d = t.drift.as_ref().filter(|d| d.stale())?;
680 let ids: Vec<&str> = d.requirements.iter().map(|q| q.id.as_str()).collect();
681 Some(format!("{} ({})", t.id, ids.join(", ")))
682 })
683 .collect();
684 if !stale.is_empty() {
685 let mut a = action(
686 "next",
687 "re-check against the spec",
688 "convergence assesses the code against the spec as it is now",
689 flag("speccoder run --converge".to_owned()),
690 );
691 a = a.with_run(Runnable::Converge);
692 out.push(item(
693 "stale",
694 None,
695 format!(
696 "the spec changed under {}",
697 count(stale.len(), "tick", "ticks")
698 ),
699 format!("proven against an earlier wording: {}", stale.join(", ")),
700 vec![a],
701 ));
702 }
703
704 if let Some(u) = unverified(r, path) {
706 let dominant = u
707 .counts
708 .first()
709 .map(|c| c.reason.clone())
710 .unwrap_or_default();
711 let mut actions = Vec::new();
712 let checks = action(
713 "next",
714 "show the plan checks",
715 "deterministic checks of the task list: where the plan leaves nothing to show",
716 flag("speccoder doctor".to_owned()),
717 );
718 match dominant.as_str() {
719 "no_scenarios" => {
720 if let Some(skill) = crate::format::at(path).planning().revises {
721 let mut a = action(
722 "plan",
723 "add scenarios",
724 "the format's clarify skill asks for the examples a test is written from",
725 flag(format!("speccoder spec {skill}")),
726 );
727 a = a.with_run(Runnable::Skill {
728 name: skill.to_owned(),
729 arguments: String::new(),
730 });
731 actions.push(a);
732 } else {
733 actions.push(checks.clone());
734 }
735 }
736 "already_passing" | "no_story"
739 if crate::format::at(path).kind() == speccoder_core::spec::FormatKind::Speckit
740 && crate::preset::state(path).installed.is_none() =>
741 {
742 let mut a = action(
743 "plan",
744 "add the speccoder preset",
745 "future task lists keep a story's behaviour inside its story; no spec file changes",
746 "speccoder preset add --yes".to_owned(),
747 );
748 a = a.with_run(Runnable::PresetAdd { update: false });
749 if !crate::preset::speckit_version().is_some_and(|v| v >= crate::preset::NEEDS) {
751 a.blocked =
752 Some("needs Spec Kit's `specify` 1.2.0 or newer on PATH".to_owned());
753 }
754 a.confirm = Some(
755 "Install the speccoder preset with the spec tool's own preset command? It writes the spec tool's preset directory and registry; no spec file changes.".to_owned(),
756 );
757 actions.push(a);
758 }
759 _ => actions.push(checks.clone()),
760 }
761 let mut converge = action(
762 "next",
763 "converge",
764 "assess the code against the spec as it is now and append what remains",
765 flag("speccoder converge".to_owned()),
766 );
767 converge = converge.with_run(Runnable::Converge);
768 actions.push(converge);
769 actions.push(action(
770 "review",
771 "open the report",
772 "each task's evidence and why",
773 flag("speccoder report".to_owned()),
774 ));
775 let mut it = item(
776 "unverified",
777 None,
778 format!(
779 "Little was verified in `{}`: {} of {} ticks are regression-only",
780 r.feature, u.regression_only, u.ticks
781 ),
782 u.counts
783 .iter()
784 .map(|c| format!("{} · {}", c.text, c.tasks.join(" ")))
785 .collect::<Vec<_>>()
786 .join("; "),
787 actions,
788 );
789 it.unverified = Some(u);
790 out.push(it);
791 }
792
793 let denied = project_state
795 .as_deref()
796 .map(|s| crate::oracle::last_denials(s, feature))
797 .unwrap_or_default();
798 if !denied.is_empty() {
799 let read = action(
800 "work",
801 "see what autopilot refused",
802 "each call was refused for that run; approve one when you run again, or allow it in Settings",
803 flag("speccoder run --json".to_owned()),
804 );
805 out.push(item(
806 "autopilot_denied",
807 None,
808 count(
809 denied.len(),
810 "call autopilot refused instead of asking",
811 "calls autopilot refused instead of asking",
812 ),
813 denied
814 .iter()
815 .take(4)
816 .map(|d| format!("{}: {}", d.task, d.object))
817 .collect::<Vec<_>>()
818 .join("; "),
819 vec![read],
820 ));
821 }
822
823 if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
825 let t = rv.totals;
826 if !rv.questions.is_empty() {
828 let read = action(
829 "plan",
830 "answer the open questions",
831 "your words replace each question where it stands",
832 flag("speccoder review".to_owned()),
833 );
834 let mut actions = vec![read];
835 if let Some(skill) = &rv.revise_with {
836 let mut a = action(
837 "plan",
838 format!("clarify with {skill}"),
839 "the format's skill asks you the questions and writes the answers in",
840 flag(format!("speccoder spec {skill}")),
841 );
842 a = a
843 .with_run(Runnable::Skill {
844 name: skill.clone(),
845 arguments: String::new(),
846 })
847 .with_role(crate::format::at(path).planning().role_of(skill));
848 actions.push(a);
849 }
850 out.push(item(
851 "question",
852 None,
853 count(
854 rv.questions.len(),
855 "open question in the spec",
856 "open questions in the spec",
857 ),
858 rv.questions
859 .iter()
860 .take(3)
861 .map(|q| format!("{}: {}", q.id, q.text))
862 .collect::<Vec<_>>()
863 .join("; "),
864 actions,
865 ));
866 }
867 let requirements = match crate::inspect(path, Some(feature)).data() {
869 Some(CommandData::Inspect(snap)) => crate::format::of(snap).oracle_requirements(snap),
870 _ => Vec::new(),
871 };
872 let ambiguous = match project_state
873 .as_deref()
874 .map(|s| crate::oracle::open_ambiguities(s, feature, &requirements))
875 {
876 Some(Ok(a)) => a,
877 None => Vec::new(),
878 Some(Err(t)) => {
880 out.push(item(
881 "ambiguous",
882 None,
883 "a test draft cannot be read".to_owned(),
884 format!("{} — Approve and Run waits until it is written again", t.0),
885 vec![action(
886 "review",
887 "see the tests",
888 "write the requirement's tests again",
889 flag("speccoder review".to_owned()),
890 )],
891 ));
892 Vec::new()
893 }
894 };
895 if let Some((req, scenario)) = ambiguous.first() {
896 let mut a = action(
897 "review",
898 "reading A is meant",
899 "the first author's tests stay",
900 flag(format!(
901 "speccoder review tests {req} --reading A {scenario}"
902 )),
903 );
904 a = a.with_run(Runnable::OracleTests {
905 req: req.clone(),
906 op: speccoder_core::oracle::OracleOp::Reading,
907 path: None,
908 scenario: Some(scenario.clone()),
909 reading: Some(speccoder_core::oracle::Reading::A),
910 });
911 let mut b = action(
912 "review",
913 "reading B is meant",
914 "its tests are written again with that reading",
915 flag(format!(
916 "speccoder review tests {req} --reading B {scenario}"
917 )),
918 );
919 b = b.with_run(Runnable::OracleTests {
920 req: req.clone(),
921 op: speccoder_core::oracle::OracleOp::Reading,
922 path: None,
923 scenario: Some(scenario.clone()),
924 reading: Some(speccoder_core::oracle::Reading::B),
925 });
926 out.push(item(
927 "ambiguous",
928 None,
929 count(
930 ambiguous.len(),
931 "scenario read two ways",
932 "scenarios read two ways",
933 ),
934 ambiguous
935 .iter()
936 .map(|(r, s)| format!("{r}/{s}"))
937 .collect::<Vec<_>>()
938 .join(", ")
939 + " — Approve and Run waits until you choose a reading or reword",
940 vec![a, b],
941 ));
942 }
943 if t.open + t.lapsed > 0 {
944 let pending: Vec<&str> = rv
945 .requirements
946 .iter()
947 .filter(|i| i.state == "open" || i.state == "lapsed")
948 .map(|i| i.id.as_str())
949 .collect();
950 let read = action(
951 "plan",
952 "read the requirements",
953 "each one, as the result will be judged against it",
954 flag("speccoder review".to_owned()),
955 );
956 let mut all = action(
957 "plan",
958 format!(
959 "approve {}",
960 count(pending.len(), "requirement", "requirements")
961 ),
962 "on their current wording, as you",
963 flag("speccoder review approve".to_owned()),
964 );
965 all = all.with_run(Runnable::Review {
966 ids: Vec::new(),
967 verdict: "approve".to_owned(),
968 });
969 out.push(item(
970 "review",
971 None,
972 if pending.len() == 1 {
973 "1 requirement waits for your review".to_owned()
974 } else {
975 format!("{} requirements wait for your review", pending.len())
976 },
977 if t.lapsed > 0 {
978 format!(
979 "{}; {} changed since you decided",
980 pending.join(", "),
981 t.lapsed
982 )
983 } else {
984 pending.join(", ")
985 },
986 vec![read, all],
987 ));
988 }
989 if t.changes_requested > 0 {
990 let asked: Vec<String> = rv
991 .requirements
992 .iter()
993 .filter(|i| i.state == "changes_requested")
994 .map(|i| format!("{}: {}", i.id, i.comment.as_deref().unwrap_or_default()))
995 .collect();
996 let mut actions = Vec::new();
997 if let Some(skill) = &rv.revise_with {
998 let mut a = action(
999 "plan",
1000 format!("revise the spec ({skill})"),
1001 "the format's skill rewrites what you asked; you review the result",
1002 flag(format!("speccoder spec {skill} \"…\"")),
1003 );
1004 a = a
1005 .with_run(Runnable::Skill {
1006 name: skill.clone(),
1007 arguments: rv.revision.clone().unwrap_or_default(),
1008 })
1009 .with_role(crate::format::at(path).planning().role_of(skill));
1010 actions.push(a);
1011 }
1012 actions.push(action(
1013 "plan",
1014 "edit the spec yourself",
1015 "then approve the new wording",
1016 flag("speccoder review".to_owned()),
1017 ));
1018 out.push(item(
1019 "revise",
1020 None,
1021 format!(
1022 "{} on the spec",
1023 count(t.changes_requested, "change request", "change requests")
1024 ),
1025 asked.join("; "),
1026 actions,
1027 ));
1028 }
1029 }
1030
1031 if !r.uncommitted.is_empty() {
1032 let mut a = action(
1033 "next",
1034 format!("commit the planning files ({})", r.uncommitted.len()),
1035 "a run reads committed files only",
1036 format!(
1037 "git add {0} && git commit -m \"spec: …\" -- {0}",
1038 r.uncommitted.join(" ")
1039 ),
1040 );
1041 a = a.with_run(Runnable::Commit {
1042 paths: r.uncommitted.clone(),
1043 });
1044 out.push(item(
1045 "commit",
1046 None,
1047 "planning files not committed yet".to_owned(),
1048 format!("read them, then commit: {}", r.uncommitted.join(", ")),
1049 vec![a],
1050 ));
1051 }
1052
1053 if handoff(r) && stale.is_empty() {
1054 let mut pr = action(
1055 "review",
1056 "open a draft pull request",
1057 "push the branch and its evidence; open (or update) the draft",
1058 flag("speccoder pr --yes".to_owned()),
1059 );
1060 pr = pr.with_run(Runnable::PullRequest);
1061 pr.confirm = Some(
1062 "Push the feature's speccoder branch and its attestation notes to `origin`, and open (or update) its draft pull request on GitHub, as you (your token).".to_owned(),
1063 );
1064 out.push(item(
1065 "handoff",
1066 None,
1067 format!("every task is done: review {} and hand it off", r.feature),
1068 format!(
1069 "{} on {} not merged",
1070 count(r.unmerged_commits, "commit", "commits"),
1071 r.branch.as_deref().unwrap_or_default()
1072 ),
1073 vec![pr],
1074 ));
1075 }
1076 if out.iter().any(|i| EVIDENCED.contains(&i.kind.as_str())) {
1077 let risk = risk_of(path, r);
1078 for i in out
1079 .iter_mut()
1080 .filter(|i| EVIDENCED.contains(&i.kind.as_str()))
1081 {
1082 i.risk.clone_from(&risk);
1083 }
1084 }
1085 out
1086}
1087
1088fn promote_item(
1091 plane: &Path,
1092 o: &crate::ladder::Offer,
1093 item: impl Fn(String, String, Vec<Action>) -> InboxItem,
1094) -> InboxItem {
1095 let at = format!("{}/{}", o.unit, o.task);
1096 let promote = action(
1097 "review",
1098 format!("start a spec from {}", o.task),
1099 "its requirement, word for word, becomes a spec of its own; the earlier tick is inherited, not verified",
1100 format!("speccoder spec promote {at}"),
1101 )
1102 .with_run(crate::ladder::promotion(o, crate::ladder::suggested_by(plane, o)));
1103 let not_now = action(
1104 "review",
1105 "not now",
1106 "offered again only when another signal holds",
1107 format!("speccoder spec promote {at} --decline"),
1108 )
1109 .with_run(Runnable::DeclinePromotion { task: at });
1110 item(
1111 format!(
1112 "{} grew past a {}",
1113 o.task,
1114 if o.bug { "bug fix" } else { "quick change" }
1115 ),
1116 o.signals
1117 .iter()
1118 .map(|s| s.text.clone())
1119 .collect::<Vec<_>>()
1120 .join(" · "),
1121 vec![promote, not_now],
1122 )
1123}
1124
1125const EVIDENCED: [&str; 3] = ["handoff", "unverified", "stale"];
1127
1128fn risk_of(path: &Path, r: &speccoder_core::StatusReport) -> Vec<speccoder_core::RiskReason> {
1132 use speccoder_core::inbox::Risk;
1133 let target = crate::oracle::ViewConfig::at(path)
1134 .strength_target
1135 .map(|t| f64::from(t) / 100.0);
1136 let mut n = [0usize; Risk::ALL.len()];
1137 let mut bump = |risk: Risk| n[Risk::ALL.iter().position(|x| *x == risk).unwrap_or(0)] += 1;
1138 for t in &r.tasks {
1139 match t.state.as_str() {
1140 "regression_only" => bump(Risk::RegressionOnly),
1141 "attested" => bump(Risk::Attested),
1142 _ => {}
1143 }
1144 if t.drift.is_some() {
1145 bump(Risk::Stale);
1146 }
1147 let strength = t
1148 .strength
1149 .as_deref()
1150 .and_then(|s| s.split_whitespace().next())
1151 .and_then(|v| v.parse::<f64>().ok());
1152 if t.state == "verified"
1153 && let (Some(s), Some(min)) = (strength, target)
1154 && s < min
1155 {
1156 bump(Risk::StrengthBelowTarget);
1157 }
1158 }
1159 let commits: Vec<&str> = r.tasks.iter().filter_map(|t| t.commit.as_deref()).collect();
1160 if !commits.is_empty() {
1161 let mut args = vec![
1164 "log",
1165 "--no-walk=unsorted",
1166 "--ignore-missing",
1167 "--format=%(trailers:key=Scope-Outside,key=Executor,separator=%x01)%x1e",
1168 ];
1169 args.extend(commits.iter().copied());
1170 if let Ok(out) = crate::git::Git::at(path).run(&args) {
1171 for block in String::from_utf8_lossy(&out).split('\u{1e}') {
1172 if block.contains("Scope-Outside:") {
1173 bump(Risk::ScopeOutside);
1174 }
1175 if block.contains("Executor:") {
1176 bump(Risk::ExternalExecutor);
1177 }
1178 }
1179 }
1180 }
1181 Risk::ALL
1182 .iter()
1183 .zip(n)
1184 .filter(|(_, count)| *count > 0)
1185 .map(|(risk, count)| speccoder_core::RiskReason {
1186 reason: risk.as_str().to_owned(),
1187 count,
1188 text: risk.chip(count),
1189 })
1190 .collect()
1191}
1192
1193fn review_rank(i: &InboxItem) -> usize {
1197 use speccoder_core::inbox::Risk;
1198 match i.risk.first() {
1199 Some(r) => Risk::ALL
1200 .iter()
1201 .position(|x| x.as_str() == r.reason)
1202 .unwrap_or(0),
1203 None if EVIDENCED.contains(&i.kind.as_str()) => Risk::ALL.len() + 1,
1204 None => Risk::ALL.len(),
1205 }
1206}
1207
1208fn unverified(r: &StatusReport, path: &Path) -> Option<speccoder_core::Unverified> {
1212 use speccoder_core::ReasonCount;
1213 use speccoder_core::regression::RegressionReason as R;
1214 let ticks = r.totals.verified + r.totals.regression_only;
1215 let reason_of = |t: &&speccoder_core::TaskStatus| {
1218 t.regression
1219 .as_ref()
1220 .map_or(R::Unrecorded, |g| g.reason.clone())
1221 };
1222 let unproven: Vec<&speccoder_core::TaskStatus> = r
1223 .tasks
1224 .iter()
1225 .filter(|t| t.state == "regression_only")
1226 .filter(|t| {
1227 !matches!(
1228 reason_of(t),
1229 R::InProgress | R::StoryVerified | R::PlanningWork
1230 )
1231 })
1232 .collect();
1233 let regression = unproven.len();
1234 if ticks < 3 || regression * 2 <= ticks || handed_off(r, path) {
1235 return None;
1236 }
1237 let mut counts: Vec<ReasonCount> = Vec::new();
1238 for t in unproven {
1239 let reason = reason_of(&t);
1240 let name = reason.name().to_owned();
1241 let c = match counts.iter_mut().position(|c| c.reason == name) {
1242 Some(i) => &mut counts[i],
1243 None => {
1244 counts.push(ReasonCount {
1245 reason: name,
1246 n: 0,
1247 tasks: Vec::new(),
1248 text: String::new(),
1249 });
1250 let last = counts.len() - 1;
1251 &mut counts[last]
1252 }
1253 };
1254 c.n += 1;
1255 c.tasks.push(t.id.clone());
1256 }
1257 counts.sort_by(|a, b| b.n.cmp(&a.n).then(a.reason.cmp(&b.reason)));
1258 for c in &mut counts {
1259 c.text = reason_count_words(c);
1260 }
1261 Some(speccoder_core::Unverified {
1262 ticks,
1263 regression_only: regression,
1264 counts,
1265 })
1266}
1267
1268fn reason_count_words(c: &speccoder_core::ReasonCount) -> String {
1270 let n = c.n;
1271 let were = if n == 1 { "was" } else { "were" };
1272 let what = match c.reason.as_str() {
1273 "no_story" => "had no user story".to_owned(),
1274 "no_scenarios" => "had a story with no acceptance scenario".to_owned(),
1275 "acceptance_off" => format!("{were} ticked with acceptance tests off"),
1276 "author_wrote_none" => "got no test from the test author".to_owned(),
1277 "author_failed" => "had acceptance tests that could not be used".to_owned(),
1278 "already_passing" => format!("{were} already passing"),
1279 "not_red" => "had acceptance tests that did not fail first".to_owned(),
1280 "tests_not_collected" => "had acceptance tests that never ran".to_owned(),
1281 "planning_work" => format!("{were} planning work (design documents)"),
1282 "story_verified" => {
1283 format!("{were} their story's checkpoint, its scenarios verified earlier")
1284 }
1285 "regression_unchecked" => {
1286 format!(
1287 "{were} ticked on checks that failed before and after, without per-test results"
1288 )
1289 }
1290 _ => format!("{were} ticked before reasons were recorded"),
1291 };
1292 format!("{n} {what}")
1293}
1294
1295fn handed_off(r: &StatusReport, path: &Path) -> bool {
1298 let merged = !r.tasks.is_empty()
1299 && r.totals.ready == 0
1300 && r.totals.blocked == 0
1301 && r.unmerged_commits == 0
1302 && r.branch.is_some();
1303 let pushed = r.branch.as_deref().is_some_and(|b| {
1304 crate::git::Git::at(path)
1305 .line(&[
1306 "for-each-ref",
1307 "--format=%(refname)",
1308 &format!("refs/remotes/*/{b}"),
1309 ])
1310 .is_ok_and(|l| !l.trim().is_empty())
1311 });
1312 merged || pushed
1313}
1314
1315fn handoff(r: &StatusReport) -> bool {
1317 !r.tasks.is_empty()
1318 && r.totals.ready == 0
1319 && r.totals.blocked == 0
1320 && r.unmerged_commits > 0
1321 && r.branch.is_some()
1322}
1323
1324fn with_input(
1325 t: &speccoder_core::TaskStatus,
1326 verb: &str,
1327 why: &str,
1328 prompt: &str,
1329 run: Runnable,
1330 flag: &dyn Fn(String) -> String,
1331) -> Action {
1332 let option = if verb == "nudge" {
1333 "--note"
1334 } else {
1335 "--reason"
1336 };
1337 let mut a = action(
1338 "task",
1339 format!("{verb} {}", t.id),
1340 why,
1341 flag(format!("speccoder task {verb} {} {option} \"…\"", t.id)),
1342 );
1343 a = a.with_run(run);
1344 a.input = Some(prompt.to_owned());
1345 a
1346}
1347
1348fn bug_item(
1350 b: &speccoder_core::bug::BugReport,
1351 flag: &dyn Fn(String) -> String,
1352) -> Option<InboxItem> {
1353 use speccoder_core::bug::Outcome;
1354 let key = b.key();
1355 let kind = match b.outcome {
1356 Outcome::AwaitingConfirmation => Kind::BugConfirm,
1357 Outcome::CannotReproduce => Kind::BugCannotReproduce,
1358 Outcome::UnchangedNotHolding => Kind::BugUnchangedNotHolding,
1359 Outcome::AlreadySatisfied if b.commit.is_some() => return None,
1361 Outcome::AlreadySatisfied => Kind::BugAlreadySatisfied,
1362 _ if b.code.as_deref() == Some("BUG-REPRO-WEAK") => Kind::BugReproWeak,
1363 _ => return None,
1364 };
1365 let task = || b.task.clone();
1366 let mut actions = Vec::new();
1367 match kind {
1368 Kind::BugConfirm => {
1369 let digest = b.confirm_digest.clone().unwrap_or_default();
1370 let mut yes = action(
1371 "next",
1372 format!("confirm {}'s reproductions", b.task),
1373 "they show the bug: they are frozen and the fix runs",
1374 format!("speccoder bug confirm {key} --digest {digest}"),
1375 );
1376 yes = yes.with_run(Runnable::BugConfirm {
1377 task: key.clone(),
1378 digest: digest.clone(),
1379 reject: false,
1380 });
1381 let mut no = action(
1382 "next",
1383 format!("reject {}'s reproductions", b.task),
1384 "the author tries again, told why",
1385 format!("speccoder bug confirm {key} --reject --reason \"…\""),
1386 );
1387 no = no.with_run(Runnable::BugConfirm {
1388 task: key.clone(),
1389 digest,
1390 reject: true,
1391 });
1392 no.input = Some("Why they do not show the bug".to_owned());
1393 actions.extend([yes, no]);
1394 }
1395 Kind::BugAlreadySatisfied => {
1396 let mut keep = action(
1397 "next",
1398 format!("keep {}'s reproduction as a regression test", b.task),
1399 "the reproduction is committed and the task accepted on your word: attested, not verified",
1400 format!("speccoder bug keep {key} --reason \"kept as a regression test\""),
1401 );
1402 keep = keep.with_run(Runnable::BugKeep { task: key.clone() });
1403 keep.input = Some("Why it is kept".to_owned());
1404 let mut drop = action(
1405 "task",
1406 format!("drop {}", b.task),
1407 "it is fixed already",
1408 flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1409 );
1410 drop = drop.with_run(Runnable::Skip { task: task() });
1411 drop.input = Some("Why it is dropped".to_owned());
1412 actions.extend([keep, drop]);
1413 }
1414 Kind::BugReproWeak => {
1415 let mut rule = action(
1416 "task",
1417 format!("rule on {}", b.task),
1418 "the tests are right: the special case is not a fix",
1419 flag(format!(
1420 "speccoder task rule {} --tests-right --reason \"…\"",
1421 b.task
1422 )),
1423 );
1424 rule = rule.with_run(Runnable::Rule { task: task() });
1425 rule.input = Some("Why the tests are right".to_owned());
1426 actions.push(rule);
1427 actions.push(action(
1428 "task",
1429 "bring your own test",
1430 "one per Expected statement",
1431 format!(
1432 "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1433 b.triad.current.text
1434 ),
1435 ));
1436 }
1437 _ => {
1438 let mut again = action(
1439 "next",
1440 format!("reproduce {} again", b.task),
1441 "a fresh set of tries",
1442 format!("speccoder bug run --task {key}"),
1443 );
1444 again = again.with_run(Runnable::BugRun {
1445 slug: None,
1446 task: Some(key.clone()),
1447 });
1448 actions.push(again);
1449 actions.push(action(
1450 "task",
1451 "bring your own test",
1452 "one per Expected statement",
1453 format!(
1454 "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1455 b.triad.current.text
1456 ),
1457 ));
1458 if !b.home.kind.native() {
1459 actions.push(action(
1460 "task",
1461 format!("reword {}", b.requirement),
1462 "the triad in other words (what was confirmed lapses)",
1463 format!(
1464 "speccoder review edit {} \"…\" --feature {}",
1465 b.requirement, b.unit
1466 ),
1467 ));
1468 let mut drop = action(
1469 "task",
1470 format!("drop {}", b.task),
1471 "nothing is fixed",
1472 flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1473 );
1474 drop = drop.with_run(Runnable::Skip { task: task() });
1475 drop.input = Some("Why it is dropped".to_owned());
1476 actions.push(drop);
1477 }
1478 }
1479 }
1480 let detail = b
1481 .handoff
1482 .as_ref()
1483 .map(|h| {
1484 format!(
1485 "{} tries; reproduced: {}; not reproduced: {}; already passing: {}",
1486 h.tries.len(),
1487 if h.statements.reproduced.is_empty() {
1488 "none".to_owned()
1489 } else {
1490 h.statements.reproduced.join(", ")
1491 },
1492 if h.statements.not_reproduced.is_empty() {
1493 "none".to_owned()
1494 } else {
1495 h.statements.not_reproduced.join(", ")
1496 },
1497 if h.statements.satisfied.is_empty() {
1498 "none".to_owned()
1499 } else {
1500 h.statements.satisfied.join(", ")
1501 },
1502 )
1503 })
1504 .unwrap_or_else(|| format!("{} — {}", b.title, b.outcome.as_str().replace('_', " ")));
1505 Some(InboxItem {
1506 unverified: None,
1507 risk: Vec::new(),
1508 tool: None,
1509 kind: kind.as_str().to_owned(),
1510 group: kind.group().as_str().to_owned(),
1511 feature: b.unit.clone(),
1512 task: Some(b.task.clone()),
1513 title: match kind {
1514 Kind::BugConfirm => format!(
1515 "{}: {} reproduction(s) wait for your confirmation",
1516 b.task,
1517 b.reproductions.iter().filter(|r| r.reproduces()).count()
1518 ),
1519 Kind::BugAlreadySatisfied => format!(
1520 "{}: already fixed — keep the reproduction or drop it",
1521 b.task
1522 ),
1523 Kind::BugReproWeak => {
1524 format!("{}: a special-case fix passes the reproductions", b.task)
1525 }
1526 Kind::BugUnchangedNotHolding => {
1527 format!("{}: an Unchanged statement does not hold today", b.task)
1528 }
1529 _ => format!("{}: could not be reproduced", b.task),
1530 },
1531 detail,
1532 since: b.confirmation.as_ref().map(|c| c.at),
1533 actions,
1534 })
1535}
1536
1537fn retry_blocked(r: &StatusReport, waiting: &[&str]) -> Option<String> {
1540 if !r.uncommitted.is_empty() {
1541 Some("commit the planning files first".to_owned())
1542 } else {
1543 waiting
1544 .first()
1545 .map(|w| format!("{w} waits for your decision and holds the worktree: decide it first"))
1546 }
1547}
1548
1549fn dependents(tasks: &[speccoder_core::TaskStatus], id: &str) -> Vec<String> {
1551 let mut waiting: Vec<&str> = vec![id];
1552 loop {
1553 let more: Vec<&str> = tasks
1554 .iter()
1555 .filter(|t| !waiting.contains(&t.id.as_str()))
1556 .filter(|t| t.blocked_by.iter().any(|b| waiting.contains(&b.as_str())))
1557 .map(|t| t.id.as_str())
1558 .collect();
1559 if more.is_empty() {
1560 break;
1561 }
1562 waiting.extend(more);
1563 }
1564 tasks
1565 .iter()
1566 .filter(|t| t.id != id && waiting.contains(&t.id.as_str()))
1567 .map(|t| t.id.clone())
1568 .collect()
1569}
1570
1571fn yours_actions(
1574 t: &speccoder_core::TaskStatus,
1575 waiting: &[String],
1576 retry: Option<String>,
1577 flag: &dyn Fn(String) -> String,
1578) -> Vec<Action> {
1579 let mut done = action(
1580 "task",
1581 format!("I did it: {}", t.id),
1582 "tick it on your word (attested, never shown as verified)",
1583 flag(format!("speccoder task done {}", t.id)),
1584 );
1585 done = done.with_run(Runnable::Done { task: t.id.clone() });
1586 let mut hint = action(
1587 "task",
1588 format!("hint & retry {}", t.id),
1589 "a note the agent reads, then a new attempt now",
1590 flag(format!("speccoder task hint {} \"…\"", t.id)),
1591 );
1592 hint = hint.with_run(Runnable::Hint { task: t.id.clone() });
1593 hint.input = Some("The hint".to_owned());
1594 hint.blocked = retry;
1595 let mut skip = skip(t, flag);
1596 if !waiting.is_empty() {
1597 let stay = if waiting.len() == 1 { "stays" } else { "stay" };
1598 skip.why = format!("runs pass it by; {} {stay} blocked", waiting.join(", "));
1599 }
1600 vec![done, hint, skip]
1601}
1602
1603fn nudge(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1604 with_input(
1605 t,
1606 "nudge",
1607 "a note its next attempts read",
1608 "The note",
1609 Runnable::Nudge { task: t.id.clone() },
1610 flag,
1611 )
1612}
1613
1614fn skip(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1615 with_input(
1616 t,
1617 "skip",
1618 "runs pass it by; what depends on it stays blocked",
1619 "Why it is skipped",
1620 Runnable::Skip { task: t.id.clone() },
1621 flag,
1622 )
1623}
1624
1625fn accept(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1626 with_input(
1627 t,
1628 "accept",
1629 "tick it on your word (attested, never shown as verified)",
1630 "Why it is done",
1631 Runnable::Accept { task: t.id.clone() },
1632 flag,
1633 )
1634}
1635
1636fn try_with(
1640 task: &str,
1641 last: &speccoder_core::LastAttempt,
1642 flag: &dyn Fn(String) -> String,
1643 on_path: impl Fn(&str) -> bool,
1644) -> Option<Action> {
1645 let current = last
1646 .run_id
1647 .as_deref()
1648 .and_then(|r| r.strip_prefix("external_"))
1649 .and_then(|r| r.split('_').next())
1650 .filter(|a| *a != "command")?;
1651 let next = crate::acp::ready::next_after(Some(current), &on_path)?;
1652 Some(
1653 action(
1654 "work",
1655 format!("try {task} with {}", next.name),
1656 "the same task, another agent: one attempt, judged the same way",
1657 flag(format!("speccoder run --task {task} --agent {}", next.id)),
1658 )
1659 .with_run(Runnable::Run {
1660 task: Some(task.to_owned()),
1661 agent: Some(next.id.to_owned()),
1662 cap: None,
1663 }),
1664 )
1665}
1666
1667#[cfg(test)]
1668mod try_with_tests {
1669 use super::*;
1670
1671 #[test]
1672 fn a_failed_agent_attempt_offers_the_next_agent_and_a_native_one_does_not() {
1673 let t = "T005";
1674 let last = |run: &str| speccoder_core::LastAttempt {
1675 outcome: "verification_failed".to_owned(),
1676 reason: String::new(),
1677 run_id: Some(run.to_owned()),
1678 reference: String::new(),
1679 story_open: Vec::new(),
1680 own_test: None,
1681 };
1682 let flag = |c: String| c;
1683 let both = |x: &str| x == "copilot" || x == "opencode";
1684 let a = try_with(t, &last("external_copilot_1"), &flag, both)
1685 .unwrap_or_else(|| panic!("offered"));
1686 assert_eq!(a.command, "speccoder run --task T005 --agent opencode");
1687 assert_eq!(
1688 a.run,
1689 Some(Runnable::Run {
1690 task: Some("T005".to_owned()),
1691 agent: Some("opencode".to_owned()),
1692 cap: None
1693 })
1694 );
1695 assert!(try_with(t, &last("run_01ABC"), &flag, both).is_none());
1696 assert!(try_with(t, &last("external_command_1"), &flag, both).is_none());
1697 let alone = |x: &str| x == "copilot";
1698 assert!(try_with(t, &last("external_copilot_1"), &flag, alone).is_none());
1699 }
1700}
1701
1702#[cfg(test)]
1703mod order_tests {
1704 use super::*;
1705
1706 fn at(kind: &str, feature: &str, since: Option<u64>, risk: &[(&str, usize)]) -> InboxItem {
1707 InboxItem {
1708 kind: kind.to_owned(),
1709 group: Kind::named(kind)
1710 .map_or("blocked_on_you", |k| k.group().as_str())
1711 .to_owned(),
1712 feature: feature.to_owned(),
1713 task: None,
1714 title: String::new(),
1715 detail: String::new(),
1716 since,
1717 actions: Vec::new(),
1718 unverified: None,
1719 tool: None,
1720 risk: risk
1721 .iter()
1722 .map(|(r, n)| speccoder_core::RiskReason {
1723 reason: (*r).to_owned(),
1724 count: *n,
1725 text: String::new(),
1726 })
1727 .collect(),
1728 }
1729 }
1730
1731 #[test]
1732 fn blocked_comes_first_oldest_first_and_review_riskiest_first() {
1733 let mut items = vec![
1734 at("handoff", "strong", None, &[]),
1735 at("review", "spec", None, &[]),
1736 at("handoff", "attested", None, &[("attested", 1)]),
1737 at("question", "q", Some(20), &[]),
1738 at(
1739 "unverified",
1740 "regression",
1741 None,
1742 &[("regression_only", 3), ("attested", 1)],
1743 ),
1744 at("approval", "a", Some(30), &[]),
1745 at("stale", "stale", None, &[("stale", 2)]),
1746 at("unknown", "unreadable", None, &[]),
1747 at("stuck", "s", Some(10), &[]),
1748 ];
1749 sort(&mut items);
1750 let order: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
1751 assert_eq!(
1752 order,
1753 [
1754 "unreadable",
1756 "s",
1757 "q",
1758 "a",
1759 "regression",
1761 "attested",
1762 "stale",
1763 "spec",
1764 "strong",
1765 ]
1766 );
1767 }
1768}