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