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