Skip to main content

speccoder_runtime/
inbox.rs

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