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