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        // The story's last task stopped with scenarios red: a person may
507        // rule a scenario's tests wrong — that scenario's tests are written
508        // again, nothing else of the oracle.
509        let 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: 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        // A step only a person can take — a `[YOU]` task that is ready, or
532        // an honest stop: the agent's reason as its claim, and three ways on.
533        if t.lane == speccoder_core::lane::Lane::Yours
534            && t.last_attempt
535                .as_ref()
536                .map_or(t.state == "ready", |a| a.outcome == "blocked")
537        {
538            let (title, detail) = match &t.last_attempt {
539                Some(a) => (
540                    format!("{} needs you: the agent stopped", t.id),
541                    a.reason.clone(),
542                ),
543                // Marked `[YOU]`: no claim to show.
544                None => (format!("{} is yours: {}", t.id, t.title), String::new()),
545            };
546            out.push(item(
547                "yours",
548                Some(&t.id),
549                title,
550                detail,
551                rulings
552                    .clone()
553                    .into_iter()
554                    .chain(yours_actions(
555                        t,
556                        &dependents(&r.tasks, &t.id),
557                        retry_blocked(r, &waiting),
558                        &flag,
559                    ))
560                    .collect(),
561            ));
562            continue;
563        }
564        let Some(a) = &t.last_attempt else {
565            continue;
566        };
567        if a.outcome == "suspected_gaming" {
568            out.push(item(
569                "gaming",
570                Some(&t.id),
571                format!("{}: the monitor suspects a shortcut", t.id),
572                a.reason.clone(),
573                vec![nudge(t, &flag), skip(t, &flag), accept(t, &flag)],
574            ));
575            continue;
576        }
577        let mut actions: Vec<_> = rulings;
578        if t.state == "ready" {
579            let mut again = action(
580                "work",
581                format!("run {} again", t.id),
582                "with what the last attempt left: its facts and your notes",
583                flag(format!("speccoder run --task {}", t.id)),
584            );
585            again = again.with_run(Runnable::Run {
586                task: Some(t.id.clone()),
587                agent: None,
588            });
589            again.blocked = retry_blocked(r, &waiting);
590            let other = try_with(&t.id, a, &flag, crate::acp::agent::on_path);
591            actions.push(again);
592            if let Some(mut other) = other {
593                other.blocked = retry_blocked(r, &waiting);
594                actions.push(other);
595            }
596        }
597        actions.extend([nudge(t, &flag), skip(t, &flag), accept(t, &flag)]);
598        out.push(item(
599            "stuck",
600            Some(&t.id),
601            format!("{}'s last attempt: {}", t.id, a.outcome.replace('_', " ")),
602            a.reason.clone(),
603            actions,
604        ));
605    }
606
607    // Changes a run found and set aside: kept on the branch, or let go.
608    for t in &r.tasks {
609        let Some(a) = &t.set_aside else { continue };
610        let n = a.files.len();
611        let mut keep = action(
612            "task",
613            format!("keep the changes set aside before {}", t.id),
614            "committed onto the feature's branch as yours",
615            flag(format!("speccoder task restore {}", t.id)),
616        );
617        keep = keep.with_run(Runnable::KeepSetAside { task: t.id.clone() });
618        let mut drop = action(
619            "task",
620            format!("discard the changes set aside before {}", t.id),
621            "deleted; the branch stays as it is",
622            flag(format!("speccoder task drop {}", t.id)),
623        );
624        drop = drop.with_run(Runnable::DropSetAside { task: t.id.clone() });
625        drop.confirm = Some(format!(
626            "Discard the {n} changed file(s) set aside before {}? They are deleted, not kept anywhere.",
627            t.id
628        ));
629        out.push(item(
630            "aside",
631            Some(&t.id),
632            format!("Changes were set aside before {} ran", t.id),
633            format!(
634                "{n} file(s) were changed in the worktree when the run started — your edits, or an interrupted run's: {}",
635                a.files.iter().take(4).cloned().collect::<Vec<_>>().join(", ")
636                    + if n > 4 { ", …" } else { "" }
637            ),
638            vec![keep, drop],
639        ));
640    }
641
642    // A quick change or bug that grew: one offer per task, on recorded
643    // facts (never a model's opinion), never a notification.
644    if let Some(state) = state {
645        let plane = crate::worktree::project_state_dir(state, path);
646        for o in crate::ladder::offers(path, &plane, feature) {
647            out.push(promote_item(&plane, &o, |t, d, a| {
648                item("promote", Some(&o.task), t, d, a)
649            }));
650        }
651    }
652
653    let stale: Vec<String> = r
654        .tasks
655        .iter()
656        .filter_map(|t| {
657            let d = t.drift.as_ref().filter(|d| d.stale())?;
658            let ids: Vec<&str> = d.requirements.iter().map(|q| q.id.as_str()).collect();
659            Some(format!("{} ({})", t.id, ids.join(", ")))
660        })
661        .collect();
662    if !stale.is_empty() {
663        let mut a = action(
664            "next",
665            "re-check against the spec",
666            "convergence assesses the code against the spec as it is now",
667            flag("speccoder run --converge".to_owned()),
668        );
669        a = a.with_run(Runnable::Converge);
670        out.push(item(
671            "stale",
672            None,
673            format!(
674                "the spec changed under {}",
675                count(stale.len(), "tick", "ticks")
676            ),
677            format!("proven against an earlier wording: {}", stale.join(", ")),
678            vec![a],
679        ));
680    }
681
682    // Most ticks regression-only: one diagnosis, never stored, never a stop.
683    if let Some(u) = unverified(r, path) {
684        let dominant = u
685            .counts
686            .first()
687            .map(|c| c.reason.clone())
688            .unwrap_or_default();
689        let mut actions = Vec::new();
690        let checks = action(
691            "next",
692            "show the plan checks",
693            "deterministic checks of the task list: where the plan leaves nothing to show",
694            flag("speccoder doctor".to_owned()),
695        );
696        match dominant.as_str() {
697            "no_scenarios" => {
698                if let Some(skill) = crate::format::at(path).planning().revises {
699                    let mut a = action(
700                        "plan",
701                        "add scenarios",
702                        "the format's clarify skill asks for the examples a test is written from",
703                        flag(format!("speccoder spec {skill}")),
704                    );
705                    a = a.with_run(Runnable::Skill {
706                        name: skill.to_owned(),
707                        arguments: String::new(),
708                    });
709                    actions.push(a);
710                } else {
711                    actions.push(checks.clone());
712                }
713            }
714            // The plan put the behaviour early or outside a story: the
715            // preset changes how future plans are written.
716            "already_passing" | "no_story"
717                if crate::format::at(path).kind() == speccoder_core::spec::FormatKind::Speckit
718                    && crate::preset::state(path).installed.is_none() =>
719            {
720                let mut a = action(
721                    "plan",
722                    "add the speccoder preset",
723                    "future task lists keep a story's behaviour inside its story; no spec file changes",
724                    "speccoder preset add --yes".to_owned(),
725                );
726                a = a.with_run(Runnable::PresetAdd { update: false });
727                // Refused anyway without Spec Kit: say why, never offer it bare.
728                if !crate::preset::speckit_version().is_some_and(|v| v >= crate::preset::NEEDS) {
729                    a.blocked =
730                        Some("needs Spec Kit's `specify` 1.2.0 or newer on PATH".to_owned());
731                }
732                a.confirm = Some(
733                    "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(),
734                );
735                actions.push(a);
736            }
737            _ => actions.push(checks.clone()),
738        }
739        let mut converge = action(
740            "next",
741            "converge",
742            "assess the code against the spec as it is now and append what remains",
743            flag("speccoder converge".to_owned()),
744        );
745        converge = converge.with_run(Runnable::Converge);
746        actions.push(converge);
747        actions.push(action(
748            "review",
749            "open the report",
750            "each task's evidence and why",
751            flag("speccoder report".to_owned()),
752        ));
753        let mut it = item(
754            "unverified",
755            None,
756            format!(
757                "Little was verified in `{}`: {} of {} ticks are regression-only",
758                r.feature, u.regression_only, u.ticks
759            ),
760            u.counts
761                .iter()
762                .map(|c| format!("{} · {}", c.text, c.tasks.join(" ")))
763                .collect::<Vec<_>>()
764                .join("; "),
765            actions,
766        );
767        it.unverified = Some(u);
768        out.push(it);
769    }
770
771    // What the last autopilot run refused instead of asking.
772    let denied = project_state
773        .as_deref()
774        .map(|s| crate::oracle::last_denials(s, feature))
775        .unwrap_or_default();
776    if !denied.is_empty() {
777        let read = action(
778            "work",
779            "see what autopilot refused",
780            "each call was refused for that run; approve one when you run again, or allow it in Settings",
781            flag("speccoder run --json".to_owned()),
782        );
783        out.push(item(
784            "autopilot_denied",
785            None,
786            count(
787                denied.len(),
788                "call autopilot refused instead of asking",
789                "calls autopilot refused instead of asking",
790            ),
791            denied
792                .iter()
793                .take(4)
794                .map(|d| format!("{}: {}", d.task, d.object))
795                .collect::<Vec<_>>()
796                .join("; "),
797            vec![read],
798        ));
799    }
800
801    // The spec, reviewed requirement by requirement.
802    if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
803        let t = rv.totals;
804        // The documents' open questions: answered before anything is built.
805        if !rv.questions.is_empty() {
806            let read = action(
807                "plan",
808                "answer the open questions",
809                "your words replace each question where it stands",
810                flag("speccoder review".to_owned()),
811            );
812            let mut actions = vec![read];
813            if let Some(skill) = &rv.revise_with {
814                let mut a = action(
815                    "plan",
816                    format!("clarify with {skill}"),
817                    "the format's skill asks you the questions and writes the answers in",
818                    flag(format!("speccoder spec {skill}")),
819                );
820                a = a
821                    .with_run(Runnable::Skill {
822                        name: skill.clone(),
823                        arguments: String::new(),
824                    })
825                    .with_role(crate::format::at(path).planning().role_of(skill));
826                actions.push(a);
827            }
828            out.push(item(
829                "question",
830                None,
831                count(
832                    rv.questions.len(),
833                    "open question in the spec",
834                    "open questions in the spec",
835                ),
836                rv.questions
837                    .iter()
838                    .take(3)
839                    .map(|q| format!("{}: {}", q.id, q.text))
840                    .collect::<Vec<_>>()
841                    .join("; "),
842                actions,
843            ));
844        }
845        // Two test authors read a requirement two ways.
846        let requirements = match crate::inspect(path, Some(feature)).data() {
847            Some(CommandData::Inspect(snap)) => crate::format::of(snap).oracle_requirements(snap),
848            _ => Vec::new(),
849        };
850        let ambiguous = match project_state
851            .as_deref()
852            .map(|s| crate::oracle::open_ambiguities(s, feature, &requirements))
853        {
854            Some(Ok(a)) => a,
855            None => Vec::new(),
856            // Unknown is never "nothing read two ways": a person looks.
857            Some(Err(t)) => {
858                out.push(item(
859                    "ambiguous",
860                    None,
861                    "a test draft cannot be read".to_owned(),
862                    format!("{} — Approve and Run waits until it is written again", t.0),
863                    vec![action(
864                        "review",
865                        "see the tests",
866                        "write the requirement's tests again",
867                        flag("speccoder review".to_owned()),
868                    )],
869                ));
870                Vec::new()
871            }
872        };
873        if let Some((req, scenario)) = ambiguous.first() {
874            let mut a = action(
875                "review",
876                "reading A is meant",
877                "the first author's tests stay",
878                flag(format!(
879                    "speccoder review tests {req} --reading A {scenario}"
880                )),
881            );
882            a = a.with_run(Runnable::OracleTests {
883                req: req.clone(),
884                op: speccoder_core::oracle::OracleOp::Reading,
885                path: None,
886                scenario: Some(scenario.clone()),
887                reading: Some(speccoder_core::oracle::Reading::A),
888            });
889            let mut b = action(
890                "review",
891                "reading B is meant",
892                "its tests are written again with that reading",
893                flag(format!(
894                    "speccoder review tests {req} --reading B {scenario}"
895                )),
896            );
897            b = b.with_run(Runnable::OracleTests {
898                req: req.clone(),
899                op: speccoder_core::oracle::OracleOp::Reading,
900                path: None,
901                scenario: Some(scenario.clone()),
902                reading: Some(speccoder_core::oracle::Reading::B),
903            });
904            out.push(item(
905                "ambiguous",
906                None,
907                count(
908                    ambiguous.len(),
909                    "scenario read two ways",
910                    "scenarios read two ways",
911                ),
912                ambiguous
913                    .iter()
914                    .map(|(r, s)| format!("{r}/{s}"))
915                    .collect::<Vec<_>>()
916                    .join(", ")
917                    + " — Approve and Run waits until you choose a reading or reword",
918                vec![a, b],
919            ));
920        }
921        if t.open + t.lapsed > 0 {
922            let pending: Vec<&str> = rv
923                .requirements
924                .iter()
925                .filter(|i| i.state == "open" || i.state == "lapsed")
926                .map(|i| i.id.as_str())
927                .collect();
928            let read = action(
929                "plan",
930                "read the requirements",
931                "each one, as the result will be judged against it",
932                flag("speccoder review".to_owned()),
933            );
934            let mut all = action(
935                "plan",
936                format!(
937                    "approve {}",
938                    count(pending.len(), "requirement", "requirements")
939                ),
940                "on their current wording, as you",
941                flag("speccoder review approve".to_owned()),
942            );
943            all = all.with_run(Runnable::Review {
944                ids: Vec::new(),
945                verdict: "approve".to_owned(),
946            });
947            out.push(item(
948                "review",
949                None,
950                if pending.len() == 1 {
951                    "1 requirement waits for your review".to_owned()
952                } else {
953                    format!("{} requirements wait for your review", pending.len())
954                },
955                if t.lapsed > 0 {
956                    format!(
957                        "{}; {} changed since you decided",
958                        pending.join(", "),
959                        t.lapsed
960                    )
961                } else {
962                    pending.join(", ")
963                },
964                vec![read, all],
965            ));
966        }
967        if t.changes_requested > 0 {
968            let asked: Vec<String> = rv
969                .requirements
970                .iter()
971                .filter(|i| i.state == "changes_requested")
972                .map(|i| format!("{}: {}", i.id, i.comment.as_deref().unwrap_or_default()))
973                .collect();
974            let mut actions = Vec::new();
975            if let Some(skill) = &rv.revise_with {
976                let mut a = action(
977                    "plan",
978                    format!("revise the spec ({skill})"),
979                    "the format's skill rewrites what you asked; you review the result",
980                    flag(format!("speccoder spec {skill} \"…\"")),
981                );
982                a = a
983                    .with_run(Runnable::Skill {
984                        name: skill.clone(),
985                        arguments: rv.revision.clone().unwrap_or_default(),
986                    })
987                    .with_role(crate::format::at(path).planning().role_of(skill));
988                actions.push(a);
989            }
990            actions.push(action(
991                "plan",
992                "edit the spec yourself",
993                "then approve the new wording",
994                flag("speccoder review".to_owned()),
995            ));
996            out.push(item(
997                "revise",
998                None,
999                format!(
1000                    "{} on the spec",
1001                    count(t.changes_requested, "change request", "change requests")
1002                ),
1003                asked.join("; "),
1004                actions,
1005            ));
1006        }
1007    }
1008
1009    if !r.uncommitted.is_empty() {
1010        let mut a = action(
1011            "next",
1012            format!("commit the planning files ({})", r.uncommitted.len()),
1013            "a run reads committed files only",
1014            format!(
1015                "git add {0} && git commit -m \"spec: …\" -- {0}",
1016                r.uncommitted.join(" ")
1017            ),
1018        );
1019        a = a.with_run(Runnable::Commit {
1020            paths: r.uncommitted.clone(),
1021        });
1022        out.push(item(
1023            "commit",
1024            None,
1025            "planning files not committed yet".to_owned(),
1026            format!("read them, then commit: {}", r.uncommitted.join(", ")),
1027            vec![a],
1028        ));
1029    }
1030
1031    if handoff(r) && stale.is_empty() {
1032        let mut pr = action(
1033            "review",
1034            "open a draft pull request",
1035            "push the branch and its evidence; open (or update) the draft",
1036            flag("speccoder pr --yes".to_owned()),
1037        );
1038        pr = pr.with_run(Runnable::PullRequest);
1039        pr.confirm = Some(
1040            "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(),
1041        );
1042        out.push(item(
1043            "handoff",
1044            None,
1045            format!("every task is done: review {} and hand it off", r.feature),
1046            format!(
1047                "{} on {} not merged",
1048                count(r.unmerged_commits, "commit", "commits"),
1049                r.branch.as_deref().unwrap_or_default()
1050            ),
1051            vec![pr],
1052        ));
1053    }
1054    if out.iter().any(|i| EVIDENCED.contains(&i.kind.as_str())) {
1055        let risk = risk_of(path, r);
1056        for i in out
1057            .iter_mut()
1058            .filter(|i| EVIDENCED.contains(&i.kind.as_str()))
1059        {
1060            i.risk.clone_from(&risk);
1061        }
1062    }
1063    out
1064}
1065
1066/// The offer to start a spec from `o`, the conversation that suggested it
1067/// preselected.
1068fn promote_item(
1069    plane: &Path,
1070    o: &crate::ladder::Offer,
1071    item: impl Fn(String, String, Vec<Action>) -> InboxItem,
1072) -> InboxItem {
1073    let at = format!("{}/{}", o.unit, o.task);
1074    let promote = action(
1075        "review",
1076        format!("start a spec from {}", o.task),
1077        "its requirement, word for word, becomes a spec of its own; the earlier tick is inherited, not verified",
1078        format!("speccoder spec promote {at}"),
1079    )
1080    .with_run(crate::ladder::promotion(o, crate::ladder::suggested_by(plane, o)));
1081    let not_now = action(
1082        "review",
1083        "not now",
1084        "offered again only when another signal holds",
1085        format!("speccoder spec promote {at} --decline"),
1086    )
1087    .with_run(Runnable::DeclinePromotion { task: at });
1088    item(
1089        format!(
1090            "{} grew past a {}",
1091            o.task,
1092            if o.bug { "bug fix" } else { "quick change" }
1093        ),
1094        o.signals
1095            .iter()
1096            .map(|s| s.text.clone())
1097            .collect::<Vec<_>>()
1098            .join(" · "),
1099        vec![promote, not_now],
1100    )
1101}
1102
1103/// The kinds about finished work, whose order follows its evidence.
1104const EVIDENCED: [&str; 3] = ["handoff", "unverified", "stale"];
1105
1106/// What a feature's ticks leave to the person, the riskiest first: their
1107/// states, drift and strength from the report, and the tick commits'
1108/// `Scope-Outside:` and `Executor:` trailers (one `git show` for all).
1109fn risk_of(path: &Path, r: &speccoder_core::StatusReport) -> Vec<speccoder_core::RiskReason> {
1110    use speccoder_core::inbox::Risk;
1111    let target = crate::oracle::ViewConfig::at(path)
1112        .strength_target
1113        .map(|t| f64::from(t) / 100.0);
1114    let mut n = [0usize; Risk::ALL.len()];
1115    let mut bump = |risk: Risk| n[Risk::ALL.iter().position(|x| *x == risk).unwrap_or(0)] += 1;
1116    for t in &r.tasks {
1117        match t.state.as_str() {
1118            "regression_only" => bump(Risk::RegressionOnly),
1119            "attested" => bump(Risk::Attested),
1120            _ => {}
1121        }
1122        if t.drift.is_some() {
1123            bump(Risk::Stale);
1124        }
1125        let strength = t
1126            .strength
1127            .as_deref()
1128            .and_then(|s| s.split_whitespace().next())
1129            .and_then(|v| v.parse::<f64>().ok());
1130        if t.state == "verified"
1131            && let (Some(s), Some(min)) = (strength, target)
1132            && s < min
1133        {
1134            bump(Risk::StrengthBelowTarget);
1135        }
1136    }
1137    let commits: Vec<&str> = r.tasks.iter().filter_map(|t| t.commit.as_deref()).collect();
1138    if !commits.is_empty() {
1139        // One call; a commit that is gone (rebased, squashed, shallow) is
1140        // left out, never the reason the others are not read.
1141        let mut args = vec![
1142            "log",
1143            "--no-walk=unsorted",
1144            "--ignore-missing",
1145            "--format=%(trailers:key=Scope-Outside,key=Executor,separator=%x01)%x1e",
1146        ];
1147        args.extend(commits.iter().copied());
1148        if let Ok(out) = crate::git::Git::at(path).run(&args) {
1149            for block in String::from_utf8_lossy(&out).split('\u{1e}') {
1150                if block.contains("Scope-Outside:") {
1151                    bump(Risk::ScopeOutside);
1152                }
1153                if block.contains("Executor:") {
1154                    bump(Risk::ExternalExecutor);
1155                }
1156            }
1157        }
1158    }
1159    Risk::ALL
1160        .iter()
1161        .zip(n)
1162        .filter(|(_, count)| *count > 0)
1163        .map(|(risk, count)| speccoder_core::RiskReason {
1164            reason: risk.as_str().to_owned(),
1165            count,
1166            text: risk.chip(count),
1167        })
1168        .collect()
1169}
1170
1171/// An item's place in *Ready to review*: its riskiest reason; then work
1172/// with no evidence to weigh (a spec to review, set-aside changes); strong
1173/// verified work last.
1174fn review_rank(i: &InboxItem) -> usize {
1175    use speccoder_core::inbox::Risk;
1176    match i.risk.first() {
1177        Some(r) => Risk::ALL
1178            .iter()
1179            .position(|x| x.as_str() == r.reason)
1180            .unwrap_or(0),
1181        None if EVIDENCED.contains(&i.kind.as_str()) => Risk::ALL.len() + 1,
1182        None => Risk::ALL.len(),
1183    }
1184}
1185
1186/// The feature's diagnosis when it has at least 3 speccoder ticks and more
1187/// than half are regression-only, and it has not been handed off (its
1188/// branch pushed, or merged with every task done).
1189fn unverified(r: &StatusReport, path: &Path) -> Option<speccoder_core::Unverified> {
1190    use speccoder_core::ReasonCount;
1191    use speccoder_core::regression::RegressionReason as R;
1192    let ticks = r.totals.verified + r.totals.regression_only;
1193    // A story's task that turned none of its scenarios, or closed a story
1194    // others turned, is progress the oracle shows — not missing evidence.
1195    let reason_of = |t: &&speccoder_core::TaskStatus| {
1196        t.regression
1197            .as_ref()
1198            .map_or(R::Unrecorded, |g| g.reason.clone())
1199    };
1200    let unproven: Vec<&speccoder_core::TaskStatus> = r
1201        .tasks
1202        .iter()
1203        .filter(|t| t.state == "regression_only")
1204        .filter(|t| {
1205            !matches!(
1206                reason_of(t),
1207                R::InProgress | R::StoryVerified | R::PlanningWork
1208            )
1209        })
1210        .collect();
1211    let regression = unproven.len();
1212    if ticks < 3 || regression * 2 <= ticks || handed_off(r, path) {
1213        return None;
1214    }
1215    let mut counts: Vec<ReasonCount> = Vec::new();
1216    for t in unproven {
1217        let reason = reason_of(&t);
1218        let name = reason.name().to_owned();
1219        let c = match counts.iter_mut().position(|c| c.reason == name) {
1220            Some(i) => &mut counts[i],
1221            None => {
1222                counts.push(ReasonCount {
1223                    reason: name,
1224                    n: 0,
1225                    tasks: Vec::new(),
1226                    text: String::new(),
1227                });
1228                let last = counts.len() - 1;
1229                &mut counts[last]
1230            }
1231        };
1232        c.n += 1;
1233        c.tasks.push(t.id.clone());
1234    }
1235    counts.sort_by(|a, b| b.n.cmp(&a.n).then(a.reason.cmp(&b.reason)));
1236    for c in &mut counts {
1237        c.text = reason_count_words(c);
1238    }
1239    Some(speccoder_core::Unverified {
1240        ticks,
1241        regression_only: regression,
1242        counts,
1243    })
1244}
1245
1246/// `7 had no user story`, `2 were already passing`.
1247fn reason_count_words(c: &speccoder_core::ReasonCount) -> String {
1248    let n = c.n;
1249    let were = if n == 1 { "was" } else { "were" };
1250    let what = match c.reason.as_str() {
1251        "no_story" => "had no user story".to_owned(),
1252        "no_scenarios" => "had a story with no acceptance scenario".to_owned(),
1253        "acceptance_off" => format!("{were} ticked with acceptance tests off"),
1254        "author_wrote_none" => "got no test from the test author".to_owned(),
1255        "author_failed" => "had acceptance tests that could not be used".to_owned(),
1256        "already_passing" => format!("{were} already passing"),
1257        "not_red" => "had acceptance tests that did not fail first".to_owned(),
1258        "tests_not_collected" => "had acceptance tests that never ran".to_owned(),
1259        "planning_work" => format!("{were} planning work (design documents)"),
1260        "story_verified" => {
1261            format!("{were} their story's checkpoint, its scenarios verified earlier")
1262        }
1263        "regression_unchecked" => {
1264            format!(
1265                "{were} ticked on checks that failed before and after, without per-test results"
1266            )
1267        }
1268        _ => format!("{were} ticked before reasons were recorded"),
1269    };
1270    format!("{n} {what}")
1271}
1272
1273/// The feature was handed off: its speccoder branch was pushed, or it is
1274/// merged with every task done.
1275fn handed_off(r: &StatusReport, path: &Path) -> bool {
1276    let merged = !r.tasks.is_empty()
1277        && r.totals.ready == 0
1278        && r.totals.blocked == 0
1279        && r.unmerged_commits == 0
1280        && r.branch.is_some();
1281    let pushed = r.branch.as_deref().is_some_and(|b| {
1282        crate::git::Git::at(path)
1283            .line(&[
1284                "for-each-ref",
1285                "--format=%(refname)",
1286                &format!("refs/remotes/*/{b}"),
1287            ])
1288            .is_ok_and(|l| !l.trim().is_empty())
1289    });
1290    merged || pushed
1291}
1292
1293/// Every task finished and the branch carries work not merged.
1294fn handoff(r: &StatusReport) -> bool {
1295    !r.tasks.is_empty()
1296        && r.totals.ready == 0
1297        && r.totals.blocked == 0
1298        && r.unmerged_commits > 0
1299        && r.branch.is_some()
1300}
1301
1302fn with_input(
1303    t: &speccoder_core::TaskStatus,
1304    verb: &str,
1305    why: &str,
1306    prompt: &str,
1307    run: Runnable,
1308    flag: &dyn Fn(String) -> String,
1309) -> Action {
1310    let option = if verb == "nudge" {
1311        "--note"
1312    } else {
1313        "--reason"
1314    };
1315    let mut a = action(
1316        "task",
1317        format!("{verb} {}", t.id),
1318        why,
1319        flag(format!("speccoder task {verb} {} {option} \"…\"", t.id)),
1320    );
1321    a = a.with_run(run);
1322    a.input = Some(prompt.to_owned());
1323    a
1324}
1325
1326/// The Needs You item of a bug that stopped for the person, if it did.
1327fn bug_item(
1328    b: &speccoder_core::bug::BugReport,
1329    flag: &dyn Fn(String) -> String,
1330) -> Option<InboxItem> {
1331    use speccoder_core::bug::Outcome;
1332    let key = b.key();
1333    let kind = match b.outcome {
1334        Outcome::AwaitingConfirmation => Kind::BugConfirm,
1335        Outcome::CannotReproduce => Kind::BugCannotReproduce,
1336        Outcome::UnchangedNotHolding => Kind::BugUnchangedNotHolding,
1337        // Kept as a regression test: decided.
1338        Outcome::AlreadySatisfied if b.commit.is_some() => return None,
1339        Outcome::AlreadySatisfied => Kind::BugAlreadySatisfied,
1340        _ if b.code.as_deref() == Some("BUG-REPRO-WEAK") => Kind::BugReproWeak,
1341        _ => return None,
1342    };
1343    let task = || b.task.clone();
1344    let mut actions = Vec::new();
1345    match kind {
1346        Kind::BugConfirm => {
1347            let digest = b.confirm_digest.clone().unwrap_or_default();
1348            let mut yes = action(
1349                "next",
1350                format!("confirm {}'s reproductions", b.task),
1351                "they show the bug: they are frozen and the fix runs",
1352                format!("speccoder bug confirm {key} --digest {digest}"),
1353            );
1354            yes = yes.with_run(Runnable::BugConfirm {
1355                task: key.clone(),
1356                digest: digest.clone(),
1357                reject: false,
1358            });
1359            let mut no = action(
1360                "next",
1361                format!("reject {}'s reproductions", b.task),
1362                "the author tries again, told why",
1363                format!("speccoder bug confirm {key} --reject --reason \"…\""),
1364            );
1365            no = no.with_run(Runnable::BugConfirm {
1366                task: key.clone(),
1367                digest,
1368                reject: true,
1369            });
1370            no.input = Some("Why they do not show the bug".to_owned());
1371            actions.extend([yes, no]);
1372        }
1373        Kind::BugAlreadySatisfied => {
1374            let mut keep = action(
1375                "next",
1376                format!("keep {}'s reproduction as a regression test", b.task),
1377                "the reproduction is committed and the task accepted on your word: attested, not verified",
1378                format!("speccoder bug keep {key} --reason \"kept as a regression test\""),
1379            );
1380            keep = keep.with_run(Runnable::BugKeep { task: key.clone() });
1381            keep.input = Some("Why it is kept".to_owned());
1382            let mut drop = action(
1383                "task",
1384                format!("drop {}", b.task),
1385                "it is fixed already",
1386                flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1387            );
1388            drop = drop.with_run(Runnable::Skip { task: task() });
1389            drop.input = Some("Why it is dropped".to_owned());
1390            actions.extend([keep, drop]);
1391        }
1392        Kind::BugReproWeak => {
1393            let mut rule = action(
1394                "task",
1395                format!("rule on {}", b.task),
1396                "the tests are right: the special case is not a fix",
1397                flag(format!(
1398                    "speccoder task rule {} --tests-right --reason \"…\"",
1399                    b.task
1400                )),
1401            );
1402            rule = rule.with_run(Runnable::Rule { task: task() });
1403            rule.input = Some("Why the tests are right".to_owned());
1404            actions.push(rule);
1405            actions.push(action(
1406                "task",
1407                "bring your own test",
1408                "one per Expected statement",
1409                format!(
1410                    "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1411                    b.triad.current.text
1412                ),
1413            ));
1414        }
1415        _ => {
1416            let mut again = action(
1417                "next",
1418                format!("reproduce {} again", b.task),
1419                "a fresh set of tries",
1420                format!("speccoder bug run --task {key}"),
1421            );
1422            again = again.with_run(Runnable::BugRun {
1423                slug: None,
1424                task: Some(key.clone()),
1425            });
1426            actions.push(again);
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            if !b.home.kind.native() {
1437                actions.push(action(
1438                    "task",
1439                    format!("reword {}", b.requirement),
1440                    "the triad in other words (what was confirmed lapses)",
1441                    format!(
1442                        "speccoder review edit {} \"…\" --feature {}",
1443                        b.requirement, b.unit
1444                    ),
1445                ));
1446                let mut drop = action(
1447                    "task",
1448                    format!("drop {}", b.task),
1449                    "nothing is fixed",
1450                    flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1451                );
1452                drop = drop.with_run(Runnable::Skip { task: task() });
1453                drop.input = Some("Why it is dropped".to_owned());
1454                actions.push(drop);
1455            }
1456        }
1457    }
1458    let detail = b
1459        .handoff
1460        .as_ref()
1461        .map(|h| {
1462            format!(
1463                "{} tries; reproduced: {}; not reproduced: {}; already passing: {}",
1464                h.tries.len(),
1465                if h.statements.reproduced.is_empty() {
1466                    "none".to_owned()
1467                } else {
1468                    h.statements.reproduced.join(", ")
1469                },
1470                if h.statements.not_reproduced.is_empty() {
1471                    "none".to_owned()
1472                } else {
1473                    h.statements.not_reproduced.join(", ")
1474                },
1475                if h.statements.satisfied.is_empty() {
1476                    "none".to_owned()
1477                } else {
1478                    h.statements.satisfied.join(", ")
1479                },
1480            )
1481        })
1482        .unwrap_or_else(|| format!("{} — {}", b.title, b.outcome.as_str().replace('_', " ")));
1483    Some(InboxItem {
1484        unverified: None,
1485        risk: Vec::new(),
1486        tool: None,
1487        kind: kind.as_str().to_owned(),
1488        group: kind.group().as_str().to_owned(),
1489        feature: b.unit.clone(),
1490        task: Some(b.task.clone()),
1491        title: match kind {
1492            Kind::BugConfirm => format!(
1493                "{}: {} reproduction(s) wait for your confirmation",
1494                b.task,
1495                b.reproductions.iter().filter(|r| r.reproduces()).count()
1496            ),
1497            Kind::BugAlreadySatisfied => format!(
1498                "{}: already fixed — keep the reproduction or drop it",
1499                b.task
1500            ),
1501            Kind::BugReproWeak => {
1502                format!("{}: a special-case fix passes the reproductions", b.task)
1503            }
1504            Kind::BugUnchangedNotHolding => {
1505                format!("{}: an Unchanged statement does not hold today", b.task)
1506            }
1507            _ => format!("{}: could not be reproduced", b.task),
1508        },
1509        detail,
1510        since: b.confirmation.as_ref().map(|c| c.at),
1511        actions,
1512    })
1513}
1514
1515/// Why a new attempt cannot start now: planning files a run cannot read
1516/// yet, or a waiting call's attempt that holds the worktree.
1517fn retry_blocked(r: &StatusReport, waiting: &[&str]) -> Option<String> {
1518    if !r.uncommitted.is_empty() {
1519        Some("commit the planning files first".to_owned())
1520    } else {
1521        waiting
1522            .first()
1523            .map(|w| format!("{w} waits for your decision and holds the worktree: decide it first"))
1524    }
1525}
1526
1527/// The open tasks that wait on `id`, directly or through another.
1528fn dependents(tasks: &[speccoder_core::TaskStatus], id: &str) -> Vec<String> {
1529    let mut waiting: Vec<&str> = vec![id];
1530    loop {
1531        let more: Vec<&str> = tasks
1532            .iter()
1533            .filter(|t| !waiting.contains(&t.id.as_str()))
1534            .filter(|t| t.blocked_by.iter().any(|b| waiting.contains(&b.as_str())))
1535            .map(|t| t.id.as_str())
1536            .collect();
1537        if more.is_empty() {
1538            break;
1539        }
1540        waiting.extend(more);
1541    }
1542    tasks
1543        .iter()
1544        .filter(|t| t.id != id && waiting.contains(&t.id.as_str()))
1545        .map(|t| t.id.clone())
1546        .collect()
1547}
1548
1549/// I did it, Hint & retry, Skip: the three ways on from a task that needs
1550/// the person.
1551fn yours_actions(
1552    t: &speccoder_core::TaskStatus,
1553    waiting: &[String],
1554    retry: Option<String>,
1555    flag: &dyn Fn(String) -> String,
1556) -> Vec<Action> {
1557    let mut done = action(
1558        "task",
1559        format!("I did it: {}", t.id),
1560        "tick it on your word (attested, never shown as verified)",
1561        flag(format!("speccoder task done {}", t.id)),
1562    );
1563    done = done.with_run(Runnable::Done { task: t.id.clone() });
1564    let mut hint = action(
1565        "task",
1566        format!("hint & retry {}", t.id),
1567        "a note the agent reads, then a new attempt now",
1568        flag(format!("speccoder task hint {} \"…\"", t.id)),
1569    );
1570    hint = hint.with_run(Runnable::Hint { task: t.id.clone() });
1571    hint.input = Some("The hint".to_owned());
1572    hint.blocked = retry;
1573    let mut skip = skip(t, flag);
1574    if !waiting.is_empty() {
1575        let stay = if waiting.len() == 1 { "stays" } else { "stay" };
1576        skip.why = format!("runs pass it by; {} {stay} blocked", waiting.join(", "));
1577    }
1578    vec![done, hint, skip]
1579}
1580
1581fn nudge(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1582    with_input(
1583        t,
1584        "nudge",
1585        "a note its next attempts read",
1586        "The note",
1587        Runnable::Nudge { task: t.id.clone() },
1588        flag,
1589    )
1590}
1591
1592fn skip(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1593    with_input(
1594        t,
1595        "skip",
1596        "runs pass it by; what depends on it stays blocked",
1597        "Why it is skipped",
1598        Runnable::Skip { task: t.id.clone() },
1599        flag,
1600    )
1601}
1602
1603fn accept(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1604    with_input(
1605        t,
1606        "accept",
1607        "tick it on your word (attested, never shown as verified)",
1608        "Why it is done",
1609        Runnable::Accept { task: t.id.clone() },
1610        flag,
1611    )
1612}
1613
1614/// The same task with the next agent that can start here: one attempt,
1615/// judged as every attempt is. Offered after an external agent's attempt
1616/// (its run is `external_<agent>_…`), never after a native one.
1617fn try_with(
1618    task: &str,
1619    last: &speccoder_core::LastAttempt,
1620    flag: &dyn Fn(String) -> String,
1621    on_path: impl Fn(&str) -> bool,
1622) -> Option<Action> {
1623    let current = last
1624        .run_id
1625        .as_deref()
1626        .and_then(|r| r.strip_prefix("external_"))
1627        .and_then(|r| r.split('_').next())
1628        .filter(|a| *a != "command")?;
1629    let next = crate::acp::ready::next_after(Some(current), &on_path)?;
1630    Some(
1631        action(
1632            "work",
1633            format!("try {task} with {}", next.name),
1634            "the same task, another agent: one attempt, judged the same way",
1635            flag(format!("speccoder run --task {task} --agent {}", next.id)),
1636        )
1637        .with_run(Runnable::Run {
1638            task: Some(task.to_owned()),
1639            agent: Some(next.id.to_owned()),
1640        }),
1641    )
1642}
1643
1644#[cfg(test)]
1645mod try_with_tests {
1646    use super::*;
1647
1648    #[test]
1649    fn a_failed_agent_attempt_offers_the_next_agent_and_a_native_one_does_not() {
1650        let t = "T005";
1651        let last = |run: &str| speccoder_core::LastAttempt {
1652            outcome: "verification_failed".to_owned(),
1653            reason: String::new(),
1654            run_id: Some(run.to_owned()),
1655            reference: String::new(),
1656            story_open: Vec::new(),
1657        };
1658        let flag = |c: String| c;
1659        let both = |x: &str| x == "copilot" || x == "opencode";
1660        let a = try_with(t, &last("external_copilot_1"), &flag, both)
1661            .unwrap_or_else(|| panic!("offered"));
1662        assert_eq!(a.command, "speccoder run --task T005 --agent opencode");
1663        assert_eq!(
1664            a.run,
1665            Some(Runnable::Run {
1666                task: Some("T005".to_owned()),
1667                agent: Some("opencode".to_owned())
1668            })
1669        );
1670        assert!(try_with(t, &last("run_01ABC"), &flag, both).is_none());
1671        assert!(try_with(t, &last("external_command_1"), &flag, both).is_none());
1672        let alone = |x: &str| x == "copilot";
1673        assert!(try_with(t, &last("external_copilot_1"), &flag, alone).is_none());
1674    }
1675}
1676
1677#[cfg(test)]
1678mod order_tests {
1679    use super::*;
1680
1681    fn at(kind: &str, feature: &str, since: Option<u64>, risk: &[(&str, usize)]) -> InboxItem {
1682        InboxItem {
1683            kind: kind.to_owned(),
1684            group: Kind::named(kind)
1685                .map_or("blocked_on_you", |k| k.group().as_str())
1686                .to_owned(),
1687            feature: feature.to_owned(),
1688            task: None,
1689            title: String::new(),
1690            detail: String::new(),
1691            since,
1692            actions: Vec::new(),
1693            unverified: None,
1694            tool: None,
1695            risk: risk
1696                .iter()
1697                .map(|(r, n)| speccoder_core::RiskReason {
1698                    reason: (*r).to_owned(),
1699                    count: *n,
1700                    text: String::new(),
1701                })
1702                .collect(),
1703        }
1704    }
1705
1706    #[test]
1707    fn blocked_comes_first_oldest_first_and_review_riskiest_first() {
1708        let mut items = vec![
1709            at("handoff", "strong", None, &[]),
1710            at("review", "spec", None, &[]),
1711            at("handoff", "attested", None, &[("attested", 1)]),
1712            at("question", "q", Some(20), &[]),
1713            at(
1714                "unverified",
1715                "regression",
1716                None,
1717                &[("regression_only", 3), ("attested", 1)],
1718            ),
1719            at("approval", "a", Some(30), &[]),
1720            at("stale", "stale", None, &[("stale", 2)]),
1721            at("unknown", "unreadable", None, &[]),
1722            at("stuck", "s", Some(10), &[]),
1723        ];
1724        sort(&mut items);
1725        let order: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
1726        assert_eq!(
1727            order,
1728            [
1729                // Blocked on you: the unreadable journal, then the oldest.
1730                "unreadable",
1731                "s",
1732                "q",
1733                "a",
1734                // Ready to review: the riskiest reason first, strong verified last.
1735                "regression",
1736                "attested",
1737                "stale",
1738                "spec",
1739                "strong",
1740            ]
1741        );
1742    }
1743}