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