Skip to main content

speccoder_runtime/
inbox.rs

1//! `speccoder inbox`: every decision that waits for a person,
2//! across the project's features, the most urgent first. When agents write
3//! the code, this is the person's work: approve or refuse a call, rule on
4//! tests, look at a suspected shortcut, settle a changed spec, decide what
5//! to do with a task that did not pass, commit a plan, hand off a branch.
6//! Read-only; every item carries the actions that decide it.
7
8use std::path::Path;
9
10use speccoder_core::actions::{Action, Runnable};
11use speccoder_core::{
12    ApprovalsReport, CommandData, CommandResult, CommandStatus, InboxItem, InboxReport,
13    StatusReport,
14};
15
16use crate::actions::action;
17
18/// The kinds, most urgent first: what blocks a run, what questions the
19/// evidence, what stops a task, what blocks a hand-off — derived from the
20/// one registry, `speccoder_core::inbox::Kind`.
21pub const KINDS: [&str; Kind::ALL.len()] = {
22    let mut out = [""; Kind::ALL.len()];
23    let mut i = 0;
24    while i < out.len() {
25        out[i] = Kind::ALL[i].as_str();
26        i += 1;
27    }
28    out
29};
30
31use speccoder_core::inbox::Kind;
32
33/// `speccoder inbox [--feature]`: the decisions waiting in every feature of
34/// the project at `path`, or in `feature` only.
35#[must_use]
36pub fn inbox(path: &Path, feature: Option<&str>) -> CommandResult {
37    inbox_with(path, feature, crate::worktree::state_dir().as_deref())
38}
39
40/// [`inbox`] with an explicit state directory (where waiting calls and
41/// held attempts are kept).
42#[must_use]
43pub fn inbox_with(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
44    let listed = crate::current::list(path, None);
45    let Some(CommandData::Features(f)) = listed.data() else {
46        return CommandResult::carried("inbox", &listed);
47    };
48    // `--feature` takes a name or a number, as everywhere: the one it selects.
49    let only = match feature {
50        Some(_) => match crate::current::list(path, feature).data() {
51            Some(CommandData::Features(one)) => one.current.clone(),
52            _ => None,
53        },
54        None => None,
55    };
56    if feature.is_some() && only.is_none() {
57        return CommandResult::carried("inbox", &crate::status::report(path, feature));
58    }
59    let names: Vec<String> = f
60        .features
61        .iter()
62        .map(|e| e.name.clone())
63        .filter(|n| only.as_ref().is_none_or(|o| o == n))
64        .collect();
65    let several = names.len() > 1;
66    let mut items = Vec::new();
67    for name in &names {
68        items.extend(of_feature(path, name, state, several));
69    }
70    // Bugs of the format's own bug structure: no feature lists them.
71    if only.is_none()
72        && let Some(s) = state
73        && let Some(project) = crate::format::at(path).detect(path)
74    {
75        let plane = crate::worktree::project_state_dir(s, &project);
76        items.extend(
77            crate::bug::state::all(&plane)
78                .iter()
79                .filter(|b| b.home.kind.native())
80                // A bug the person dropped waits for nobody.
81                .filter(|b| !crate::controls::skipped(&project, &b.unit).contains_key(&b.task))
82                .filter_map(|b| bug_item(b, &|c| c)),
83        );
84    }
85    let rank = |k: &str| KINDS.iter().position(|x| *x == k).unwrap_or(KINDS.len());
86    items.sort_by(|a, b| {
87        rank(&a.kind)
88            .cmp(&rank(&b.kind))
89            .then(
90                a.since
91                    .unwrap_or(u64::MAX)
92                    .cmp(&b.since.unwrap_or(u64::MAX)),
93            )
94            .then(a.feature.cmp(&b.feature))
95            .then(a.task.cmp(&b.task))
96    });
97    let message = summary(&items, names.len());
98    CommandResult::new("inbox", CommandStatus::Ok, &message).with_data(CommandData::Inbox(
99        Box::new(InboxReport {
100            items,
101            features: names.len(),
102        }),
103    ))
104}
105
106/// `n` with the word for one or for many: `1 tick`, `2 ticks`.
107fn count(n: usize, one: &str, many: &str) -> String {
108    format!("{n} {}", if n == 1 { one } else { many })
109}
110
111/// A kind counted in words: `2 approvals`, `1 open question`.
112fn counted(kind: &str, n: usize) -> String {
113    let (one, many) = match kind {
114        "unknown" => (
115            "feature whose waiting calls could not be read",
116            "features whose waiting calls could not be read",
117        ),
118        "approval" => ("approval", "approvals"),
119        "approval_batch" => (
120            "run whose calls wait together",
121            "runs whose calls wait together",
122        ),
123        "ruling" => ("ruling", "rulings"),
124        "gaming" => ("suspected shortcut", "suspected shortcuts"),
125        "unverified" => (
126            "feature little was verified in",
127            "features little was verified in",
128        ),
129        "stale" => (
130            "tick the spec changed under",
131            "ticks the spec changed under",
132        ),
133        "question" => ("open question", "open questions"),
134        "ambiguous" => ("scenario read two ways", "scenarios read two ways"),
135        "autopilot_denied" => ("call autopilot refused", "calls autopilot refused"),
136        "review" => ("spec to review", "specs to review"),
137        "revise" => ("change request", "change requests"),
138        "stuck" => ("task that did not pass", "tasks that did not pass"),
139        "yours" => ("step only you can take", "steps only you can take"),
140        "aside" => ("set-aside change", "set-aside changes"),
141        "commit" => ("plan to commit", "plans to commit"),
142        "handoff" => ("branch to hand off", "branches to hand off"),
143        "bug_confirm" => (
144            "bug reproduction to confirm",
145            "bug reproductions to confirm",
146        ),
147        "bug_cannot_reproduce" => (
148            "bug that could not be reproduced",
149            "bugs that could not be reproduced",
150        ),
151        "bug_unchanged_not_holding" => (
152            "bug whose Unchanged statement does not hold",
153            "bugs whose Unchanged statements do not hold",
154        ),
155        "bug_repro_weak" => ("bug reproduction too weak", "bug reproductions too weak"),
156        "bug_already_satisfied" => ("bug already fixed", "bugs already fixed"),
157        k => (k, k),
158    };
159    count(n, one, many)
160}
161
162/// `3 decisions waiting in 2 features: 1 approval, 2 tasks that did not pass`.
163fn summary(items: &[InboxItem], features: usize) -> String {
164    if items.is_empty() {
165        return format!(
166            "nothing waits for you in {}: the agents can work",
167            count(features, "feature", "features")
168        );
169    }
170    let mut in_features: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
171    in_features.sort_unstable();
172    in_features.dedup();
173    let parts: Vec<String> = KINDS
174        .iter()
175        .filter_map(|k| {
176            let n = items.iter().filter(|i| i.kind == *k).count();
177            (n > 0).then(|| counted(k, n))
178        })
179        .collect();
180    format!(
181        "{} waiting in {}: {}",
182        count(items.len(), "decision", "decisions"),
183        count(in_features.len(), "feature", "features"),
184        parts.join(", ")
185    )
186}
187
188/// One feature's items. `several`: commands name the feature.
189fn of_feature(path: &Path, feature: &str, state: Option<&Path>, several: bool) -> Vec<InboxItem> {
190    let report = crate::status::report(path, Some(feature));
191    let Some(CommandData::Report(r)) = report.data() else {
192        return Vec::new();
193    };
194    let approvals: Option<ApprovalsReport> =
195        state.and_then(
196            |s| match crate::approvals::list_with(path, Some(feature), s).data() {
197                Some(CommandData::Approvals(a)) => Some((**a).clone()),
198                _ => None,
199            },
200        );
201    let flag = |command: String| {
202        if several {
203            format!("{command} --feature {feature}")
204        } else {
205            command
206        }
207    };
208    let item = |kind: &str, task: Option<&str>, title: String, detail: String, actions| InboxItem {
209        kind: kind.to_owned(),
210        group: Kind::named(kind)
211            .map_or("blocked_on_you", |k| k.group().as_str())
212            .to_owned(),
213        feature: feature.to_owned(),
214        task: task.map(str::to_owned),
215        title,
216        detail,
217        since: None,
218        actions,
219        unverified: None,
220    };
221    let mut out = Vec::new();
222    let waiting: Vec<&str> = approvals
223        .iter()
224        .flat_map(|a| &a.pending)
225        .map(|p| p.task.as_str())
226        .collect();
227
228    // The journal could not be read: what waits there is unknown, and the
229    // person is told so rather than shown an empty list.
230    if let Some(why) = approvals.as_ref().and_then(|a| a.unreadable.as_ref()) {
231        out.push(item(
232            "unknown",
233            None,
234            format!("Calls waiting in {feature} could not be read"),
235            format!("{why} — some may be waiting for you"),
236            Vec::new(),
237        ));
238    }
239
240    // A run that used its asking budget: its waiting calls are one item,
241    // decided together.
242    let batched_runs: Vec<String> = approvals
243        .iter()
244        .flat_map(|a| &a.batch)
245        .map(|b| b.run.clone())
246        .collect();
247    for b in approvals.iter().flat_map(|a| &a.batch) {
248        let calls: Vec<String> = b.groups.iter().flat_map(|g| g.calls.clone()).collect();
249        let of_run: Vec<&speccoder_core::PendingCall> = approvals
250            .iter()
251            .flat_map(|a| &a.pending)
252            .filter(|p| p.run.as_deref() == Some(b.run.as_str()))
253            .collect();
254        let detail: Vec<String> = b
255            .groups
256            .iter()
257            .map(|g| {
258                let shown: Vec<String> = of_run
259                    .iter()
260                    .filter(|p| p.object.as_ref().map_or("write", |o| o.class.as_str()) == g.class)
261                    .map(|p| {
262                        let what = p
263                            .object
264                            .as_ref()
265                            .map_or(p.covers.clone(), |o| o.short.clone());
266                        let risk = p.presentation.as_ref().map_or("", |v| v.risk.as_str());
267                        let copied = if p.sources.is_empty() {
268                            ""
269                        } else {
270                            ", copied from content"
271                        };
272                        format!("{what} ({}, {risk}{copied})", p.task)
273                    })
274                    .collect();
275                format!("{}: {}", g.class, shown.join("; "))
276            })
277            .collect();
278        let decide = |verdict, resume_asking| Runnable::DecideBatch {
279            run: b.run.clone(),
280            verdict,
281            calls: calls.clone(),
282            digest: b.digest.clone(),
283            resume_asking,
284        };
285        let command = |verb: &str| {
286            flag(format!(
287                "speccoder approvals batch {} {verb} --digest {}",
288                b.run, b.digest
289            ))
290        };
291        let yes = action(
292            "next",
293            format!("approve the {} waiting call(s) once", calls.len()),
294            "each listed call runs once; the run keeps waiting at its next call",
295            command("approve"),
296        )
297        .with_run(decide(
298            speccoder_core::actions::BatchVerdict::Approve,
299            false,
300        ));
301        let again = action(
302            "next",
303            "approve once and resume asking",
304            "each listed call runs once, and the run prompts you again",
305            format!("{} --resume-asking", command("approve")),
306        )
307        .with_run(decide(speccoder_core::actions::BatchVerdict::Approve, true));
308        let mut no = action(
309            "next",
310            format!("reject the {} waiting call(s)", calls.len()),
311            "the model is told your reason for each",
312            format!("{} --reason \"…\"", command("reject")),
313        )
314        .with_run(decide(speccoder_core::actions::BatchVerdict::Reject, false));
315        no.input = Some("Why (the model is told)".to_owned());
316        let mut i = item(
317            "approval_batch",
318            None,
319            format!(
320                "{} call(s) wait for you in run {} (its approval budget is used)",
321                calls.len(),
322                b.run
323            ),
324            detail.join(" · "),
325            vec![yes, again, no],
326        );
327        i.since = of_run.iter().filter_map(|p| p.waiting_since).min();
328        out.push(i);
329    }
330
331    // A run waits on these.
332    for p in approvals
333        .iter()
334        .flat_map(|a| &a.pending)
335        .filter(|p| p.run.as_ref().is_none_or(|r| !batched_runs.contains(r)))
336    {
337        let question = p.kind == "question";
338        let mut yes = action(
339            "next",
340            if question {
341                format!("answer {}", p.task)
342            } else {
343                format!("approve {}", p.task)
344            },
345            if question {
346                "the attempt resumes with your answer".to_owned()
347            } else {
348                format!("allow {} for the task", p.covers)
349            },
350            flag(format!("speccoder task approve {}", p.task)),
351        );
352        yes = yes.with_run(Runnable::Approve {
353            task: p.task.clone(),
354            call: p.id(),
355            digest: p.digest.clone(),
356        });
357        if question {
358            yes.input = Some("Your answer".to_owned());
359        }
360        let mut no = action(
361            "next",
362            format!("reject {}", p.task),
363            "the model is told your reason",
364            flag(format!("speccoder task reject {} --reason \"…\"", p.task)),
365        );
366        no = no.with_run(Runnable::Reject {
367            task: p.task.clone(),
368            call: p.id(),
369            digest: p.digest.clone(),
370        });
371        no.input = Some("Why (the model is told)".to_owned());
372        let mut i = item(
373            "approval",
374            Some(&p.task),
375            if question {
376                format!("{} asks you", p.task)
377            } else {
378                format!("{} waits for your approval", p.task)
379            },
380            p.question
381                .clone()
382                .filter(|_| question)
383                .unwrap_or_else(|| format!("{} — {}", p.covers, p.reason)),
384            vec![yes, no],
385        );
386        i.since = p.waiting_since;
387        out.push(i);
388    }
389
390    let root = Path::new(path);
391    let project_state = state.and_then(|s| {
392        crate::format::at(root)
393            .detect(root)
394            .map(|project| crate::worktree::project_state_dir(s, &project))
395    });
396    for t in &r.tasks {
397        let done = matches!(
398            t.state.as_str(),
399            "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
400        );
401        if done || waiting.contains(&t.id.as_str()) {
402            continue;
403        }
404        if let Some(i) = project_state
405            .as_deref()
406            .and_then(|s| crate::bug::state::load(s, feature, &t.id))
407            .and_then(|b| bug_item(&b, &flag))
408        {
409            out.push(i);
410            continue;
411        }
412        let held = project_state
413            .as_deref()
414            .and_then(|s| crate::strength::held(&crate::strength::dir(s, feature, &t.id)));
415        if let Some(held) = held {
416            let mut rule = action(
417                "task",
418                format!("rule on {}", t.id),
419                "the tests are right: the flagged alternatives are not what the spec allows",
420                flag(format!(
421                    "speccoder task rule {} --tests-right --reason \"…\"",
422                    t.id
423                )),
424            );
425            rule = rule.with_run(Runnable::Rule { task: t.id.clone() });
426            rule.input = Some("Why the tests are right".to_owned());
427            let findings: Vec<String> = held
428                .findings
429                .iter()
430                .map(|p| format!("{} ({})", p.scenario, p.rationale))
431                .collect();
432            out.push(item(
433                "ruling",
434                Some(&t.id),
435                format!("{}'s tests reject an alternative the spec may allow", t.id),
436                findings.join("; "),
437                vec![rule, nudge(t, &flag), skip(t, &flag)],
438            ));
439            continue;
440        }
441        // A step only a person can take — a `[YOU]` task that is ready, or
442        // an honest stop: the agent's reason as its claim, and three ways on.
443        if t.lane == speccoder_core::lane::Lane::Yours
444            && t.last_attempt
445                .as_ref()
446                .map_or(t.state == "ready", |a| a.outcome == "blocked")
447        {
448            let (title, detail) = match &t.last_attempt {
449                Some(a) => (
450                    format!("{} needs you: the agent stopped", t.id),
451                    a.reason.clone(),
452                ),
453                // Marked `[YOU]`: no claim to show.
454                None => (format!("{} is yours: {}", t.id, t.title), String::new()),
455            };
456            out.push(item(
457                "yours",
458                Some(&t.id),
459                title,
460                detail,
461                yours_actions(
462                    t,
463                    &dependents(&r.tasks, &t.id),
464                    retry_blocked(r, &waiting),
465                    &flag,
466                ),
467            ));
468            continue;
469        }
470        let Some(a) = &t.last_attempt else {
471            continue;
472        };
473        if a.outcome == "suspected_gaming" {
474            out.push(item(
475                "gaming",
476                Some(&t.id),
477                format!("{}: the monitor suspects a shortcut", t.id),
478                a.reason.clone(),
479                vec![nudge(t, &flag), skip(t, &flag), accept(t, &flag)],
480            ));
481            continue;
482        }
483        let mut actions = Vec::new();
484        if t.state == "ready" {
485            let mut again = action(
486                "work",
487                format!("run {} again", t.id),
488                "with what the last attempt left: its facts and your notes",
489                flag(format!("speccoder run --task {}", t.id)),
490            );
491            again = again.with_run(Runnable::Run {
492                task: Some(t.id.clone()),
493                agent: None,
494            });
495            again.blocked = retry_blocked(r, &waiting);
496            let other = try_with(&t.id, a, &flag, crate::acp::agent::on_path);
497            actions.push(again);
498            if let Some(mut other) = other {
499                other.blocked = retry_blocked(r, &waiting);
500                actions.push(other);
501            }
502        }
503        actions.extend([nudge(t, &flag), skip(t, &flag), accept(t, &flag)]);
504        out.push(item(
505            "stuck",
506            Some(&t.id),
507            format!("{}'s last attempt: {}", t.id, a.outcome.replace('_', " ")),
508            a.reason.clone(),
509            actions,
510        ));
511    }
512
513    // Changes a run found and set aside: kept on the branch, or let go.
514    for t in &r.tasks {
515        let Some(a) = &t.set_aside else { continue };
516        let n = a.files.len();
517        let mut keep = action(
518            "task",
519            format!("keep the changes set aside before {}", t.id),
520            "committed onto the feature's branch as yours",
521            flag(format!("speccoder task restore {}", t.id)),
522        );
523        keep = keep.with_run(Runnable::KeepSetAside { task: t.id.clone() });
524        let mut drop = action(
525            "task",
526            format!("discard the changes set aside before {}", t.id),
527            "deleted; the branch stays as it is",
528            flag(format!("speccoder task drop {}", t.id)),
529        );
530        drop = drop.with_run(Runnable::DropSetAside { task: t.id.clone() });
531        drop.confirm = Some(format!(
532            "Discard the {n} changed file(s) set aside before {}? They are deleted, not kept anywhere.",
533            t.id
534        ));
535        out.push(item(
536            "aside",
537            Some(&t.id),
538            format!("Changes were set aside before {} ran", t.id),
539            format!(
540                "{n} file(s) were changed in the worktree when the run started — your edits, or an interrupted run's: {}",
541                a.files.iter().take(4).cloned().collect::<Vec<_>>().join(", ")
542                    + if n > 4 { ", …" } else { "" }
543            ),
544            vec![keep, drop],
545        ));
546    }
547
548    let stale: Vec<String> = r
549        .tasks
550        .iter()
551        .filter_map(|t| {
552            let d = t.drift.as_ref().filter(|d| d.stale())?;
553            let ids: Vec<&str> = d.requirements.iter().map(|q| q.id.as_str()).collect();
554            Some(format!("{} ({})", t.id, ids.join(", ")))
555        })
556        .collect();
557    if !stale.is_empty() {
558        let mut a = action(
559            "next",
560            "re-check against the spec",
561            "convergence assesses the code against the spec as it is now",
562            flag("speccoder run --converge".to_owned()),
563        );
564        a = a.with_run(Runnable::Converge);
565        out.push(item(
566            "stale",
567            None,
568            format!(
569                "the spec changed under {}",
570                count(stale.len(), "tick", "ticks")
571            ),
572            format!("proven against an earlier wording: {}", stale.join(", ")),
573            vec![a],
574        ));
575    }
576
577    // Most ticks regression-only: one diagnosis, never stored, never a stop.
578    if let Some(u) = unverified(r, path) {
579        let dominant = u
580            .counts
581            .first()
582            .map(|c| c.reason.clone())
583            .unwrap_or_default();
584        let mut actions = Vec::new();
585        let checks = action(
586            "next",
587            "show the plan checks",
588            "deterministic checks of the task list: where the plan leaves nothing to show",
589            flag("speccoder doctor".to_owned()),
590        );
591        match dominant.as_str() {
592            "no_scenarios" => {
593                if let Some(skill) = crate::format::at(path).planning().revises {
594                    let mut a = action(
595                        "plan",
596                        "add scenarios",
597                        "the format's clarify skill asks for the examples a test is written from",
598                        flag(format!("speccoder spec {skill}")),
599                    );
600                    a = a.with_run(Runnable::Skill {
601                        name: skill.to_owned(),
602                        arguments: String::new(),
603                    });
604                    actions.push(a);
605                } else {
606                    actions.push(checks.clone());
607                }
608            }
609            // The plan put the behaviour early or outside a story: the
610            // preset changes how future plans are written.
611            "already_passing" | "no_story"
612                if crate::format::at(path).kind() == speccoder_core::spec::FormatKind::Speckit
613                    && crate::preset::state(path).installed.is_none() =>
614            {
615                let mut a = action(
616                    "plan",
617                    "add the speccoder preset",
618                    "future task lists keep a story's behaviour inside its story; no spec file changes",
619                    "speccoder preset add --yes".to_owned(),
620                );
621                a = a.with_run(Runnable::PresetAdd { update: false });
622                // Refused anyway without Spec Kit: say why, never offer it bare.
623                if !crate::preset::speckit_version().is_some_and(|v| v >= crate::preset::NEEDS) {
624                    a.blocked =
625                        Some("needs Spec Kit's `specify` 1.2.0 or newer on PATH".to_owned());
626                }
627                a.confirm = Some(
628                    "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(),
629                );
630                actions.push(a);
631            }
632            _ => actions.push(checks.clone()),
633        }
634        let mut converge = action(
635            "next",
636            "converge",
637            "assess the code against the spec as it is now and append what remains",
638            flag("speccoder converge".to_owned()),
639        );
640        converge = converge.with_run(Runnable::Converge);
641        actions.push(converge);
642        actions.push(action(
643            "review",
644            "open the report",
645            "each task's evidence and why",
646            flag("speccoder report".to_owned()),
647        ));
648        let mut it = item(
649            "unverified",
650            None,
651            format!(
652                "Little was verified in `{}`: {} of {} ticks are regression-only",
653                r.feature, u.regression_only, u.ticks
654            ),
655            u.counts
656                .iter()
657                .map(|c| format!("{} · {}", c.text, c.tasks.join(" ")))
658                .collect::<Vec<_>>()
659                .join("; "),
660            actions,
661        );
662        it.unverified = Some(u);
663        out.push(it);
664    }
665
666    // What the last autopilot run refused instead of asking.
667    let denied = project_state
668        .as_deref()
669        .map(|s| crate::oracle::last_denials(s, feature))
670        .unwrap_or_default();
671    if !denied.is_empty() {
672        let read = action(
673            "work",
674            "see what autopilot refused",
675            "each call was refused for that run; approve one when you run again, or allow it in Settings",
676            flag("speccoder run --json".to_owned()),
677        );
678        out.push(item(
679            "autopilot_denied",
680            None,
681            count(
682                denied.len(),
683                "call autopilot refused instead of asking",
684                "calls autopilot refused instead of asking",
685            ),
686            denied
687                .iter()
688                .take(4)
689                .map(|d| format!("{}: {}", d.task, d.object))
690                .collect::<Vec<_>>()
691                .join("; "),
692            vec![read],
693        ));
694    }
695
696    // The spec, reviewed requirement by requirement.
697    if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
698        let t = rv.totals;
699        // The documents' open questions: answered before anything is built.
700        if !rv.questions.is_empty() {
701            let read = action(
702                "plan",
703                "answer the open questions",
704                "your words replace each question where it stands",
705                flag("speccoder review".to_owned()),
706            );
707            let mut actions = vec![read];
708            if let Some(skill) = &rv.revise_with {
709                let mut a = action(
710                    "plan",
711                    format!("clarify with {skill}"),
712                    "the format's skill asks you the questions and writes the answers in",
713                    flag(format!("speccoder spec {skill}")),
714                );
715                a = a
716                    .with_run(Runnable::Skill {
717                        name: skill.clone(),
718                        arguments: String::new(),
719                    })
720                    .with_role(crate::format::at(path).planning().role_of(skill));
721                actions.push(a);
722            }
723            out.push(item(
724                "question",
725                None,
726                count(
727                    rv.questions.len(),
728                    "open question in the spec",
729                    "open questions in the spec",
730                ),
731                rv.questions
732                    .iter()
733                    .take(3)
734                    .map(|q| format!("{}: {}", q.id, q.text))
735                    .collect::<Vec<_>>()
736                    .join("; "),
737                actions,
738            ));
739        }
740        // Two test authors read a requirement two ways.
741        let requirements = match crate::inspect(path, Some(feature)).data() {
742            Some(CommandData::Inspect(snap)) => crate::format::of(snap).oracle_requirements(snap),
743            _ => Vec::new(),
744        };
745        let ambiguous = match project_state
746            .as_deref()
747            .map(|s| crate::oracle::open_ambiguities(s, feature, &requirements))
748        {
749            Some(Ok(a)) => a,
750            None => Vec::new(),
751            // Unknown is never "nothing read two ways": a person looks.
752            Some(Err(t)) => {
753                out.push(item(
754                    "ambiguous",
755                    None,
756                    "a test draft cannot be read".to_owned(),
757                    format!("{} — Approve and Run waits until it is written again", t.0),
758                    vec![action(
759                        "review",
760                        "see the tests",
761                        "write the requirement's tests again",
762                        flag("speccoder review".to_owned()),
763                    )],
764                ));
765                Vec::new()
766            }
767        };
768        if let Some((req, scenario)) = ambiguous.first() {
769            let mut a = action(
770                "review",
771                "reading A is meant",
772                "the first author's tests stay",
773                flag(format!(
774                    "speccoder review tests {req} --reading A {scenario}"
775                )),
776            );
777            a = a.with_run(Runnable::OracleTests {
778                req: req.clone(),
779                op: speccoder_core::oracle::OracleOp::Reading,
780                path: None,
781                scenario: Some(scenario.clone()),
782                reading: Some(speccoder_core::oracle::Reading::A),
783            });
784            let mut b = action(
785                "review",
786                "reading B is meant",
787                "its tests are written again with that reading",
788                flag(format!(
789                    "speccoder review tests {req} --reading B {scenario}"
790                )),
791            );
792            b = b.with_run(Runnable::OracleTests {
793                req: req.clone(),
794                op: speccoder_core::oracle::OracleOp::Reading,
795                path: None,
796                scenario: Some(scenario.clone()),
797                reading: Some(speccoder_core::oracle::Reading::B),
798            });
799            out.push(item(
800                "ambiguous",
801                None,
802                count(
803                    ambiguous.len(),
804                    "scenario read two ways",
805                    "scenarios read two ways",
806                ),
807                ambiguous
808                    .iter()
809                    .map(|(r, s)| format!("{r}/{s}"))
810                    .collect::<Vec<_>>()
811                    .join(", ")
812                    + " — Approve and Run waits until you choose a reading or reword",
813                vec![a, b],
814            ));
815        }
816        if t.open + t.lapsed > 0 {
817            let pending: Vec<&str> = rv
818                .requirements
819                .iter()
820                .filter(|i| i.state == "open" || i.state == "lapsed")
821                .map(|i| i.id.as_str())
822                .collect();
823            let read = action(
824                "plan",
825                "read the requirements",
826                "each one, as the result will be judged against it",
827                flag("speccoder review".to_owned()),
828            );
829            let mut all = action(
830                "plan",
831                format!(
832                    "approve {}",
833                    count(pending.len(), "requirement", "requirements")
834                ),
835                "on their current wording, as you",
836                flag("speccoder review approve".to_owned()),
837            );
838            all = all.with_run(Runnable::Review {
839                ids: Vec::new(),
840                verdict: "approve".to_owned(),
841            });
842            out.push(item(
843                "review",
844                None,
845                if pending.len() == 1 {
846                    "1 requirement waits for your review".to_owned()
847                } else {
848                    format!("{} requirements wait for your review", pending.len())
849                },
850                if t.lapsed > 0 {
851                    format!(
852                        "{}; {} changed since you decided",
853                        pending.join(", "),
854                        t.lapsed
855                    )
856                } else {
857                    pending.join(", ")
858                },
859                vec![read, all],
860            ));
861        }
862        if t.changes_requested > 0 {
863            let asked: Vec<String> = rv
864                .requirements
865                .iter()
866                .filter(|i| i.state == "changes_requested")
867                .map(|i| format!("{}: {}", i.id, i.comment.as_deref().unwrap_or_default()))
868                .collect();
869            let mut actions = Vec::new();
870            if let Some(skill) = &rv.revise_with {
871                let mut a = action(
872                    "plan",
873                    format!("revise the spec ({skill})"),
874                    "the format's skill rewrites what you asked; you review the result",
875                    flag(format!("speccoder spec {skill} \"…\"")),
876                );
877                a = a
878                    .with_run(Runnable::Skill {
879                        name: skill.clone(),
880                        arguments: rv.revision.clone().unwrap_or_default(),
881                    })
882                    .with_role(crate::format::at(path).planning().role_of(skill));
883                actions.push(a);
884            }
885            actions.push(action(
886                "plan",
887                "edit the spec yourself",
888                "then approve the new wording",
889                flag("speccoder review".to_owned()),
890            ));
891            out.push(item(
892                "revise",
893                None,
894                format!(
895                    "{} on the spec",
896                    count(t.changes_requested, "change request", "change requests")
897                ),
898                asked.join("; "),
899                actions,
900            ));
901        }
902    }
903
904    if !r.uncommitted.is_empty() {
905        let mut a = action(
906            "next",
907            format!("commit the planning files ({})", r.uncommitted.len()),
908            "a run reads committed files only",
909            format!(
910                "git add {0} && git commit -m \"spec: …\" -- {0}",
911                r.uncommitted.join(" ")
912            ),
913        );
914        a = a.with_run(Runnable::Commit {
915            paths: r.uncommitted.clone(),
916        });
917        out.push(item(
918            "commit",
919            None,
920            "planning files not committed yet".to_owned(),
921            format!("read them, then commit: {}", r.uncommitted.join(", ")),
922            vec![a],
923        ));
924    }
925
926    if handoff(r) && stale.is_empty() {
927        let mut pr = action(
928            "review",
929            "open a draft pull request",
930            "push the branch and its evidence; open (or update) the draft",
931            flag("speccoder pr --yes".to_owned()),
932        );
933        pr = pr.with_run(Runnable::PullRequest);
934        pr.confirm = Some(
935            "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(),
936        );
937        out.push(item(
938            "handoff",
939            None,
940            format!("every task is done: review {} and hand it off", r.feature),
941            format!(
942                "{} on {} not merged",
943                count(r.unmerged_commits, "commit", "commits"),
944                r.branch.as_deref().unwrap_or_default()
945            ),
946            vec![pr],
947        ));
948    }
949    out
950}
951
952/// The feature's diagnosis when it has at least 3 speccoder ticks and more
953/// than half are regression-only, and it has not been handed off (its
954/// branch pushed, or merged with every task done).
955fn unverified(r: &StatusReport, path: &Path) -> Option<speccoder_core::Unverified> {
956    use speccoder_core::ReasonCount;
957    let ticks = r.totals.verified + r.totals.regression_only;
958    let regression = r.totals.regression_only;
959    if ticks < 3 || regression * 2 <= ticks || handed_off(r, path) {
960        return None;
961    }
962    let mut counts: Vec<ReasonCount> = Vec::new();
963    for t in r.tasks.iter().filter(|t| t.state == "regression_only") {
964        let reason = t
965            .regression
966            .as_ref()
967            .map(|g| g.reason.clone())
968            .unwrap_or(speccoder_core::regression::RegressionReason::Unrecorded);
969        let name = reason.name().to_owned();
970        let by = match &reason {
971            speccoder_core::regression::RegressionReason::AlreadyPassing {
972                implemented_by: Some(speccoder_core::regression::ImplementedBy::Task { task, .. }),
973            } => Some(task.clone()),
974            _ => None,
975        };
976        let c = match counts.iter_mut().position(|c| c.reason == name) {
977            Some(i) => &mut counts[i],
978            None => {
979                counts.push(ReasonCount {
980                    reason: name,
981                    n: 0,
982                    tasks: Vec::new(),
983                    implemented_by: Vec::new(),
984                    text: String::new(),
985                });
986                let last = counts.len() - 1;
987                &mut counts[last]
988            }
989        };
990        c.n += 1;
991        c.tasks.push(t.id.clone());
992        if let Some(b) = by.filter(|b| !c.implemented_by.contains(b)) {
993            c.implemented_by.push(b);
994        }
995    }
996    counts.sort_by(|a, b| b.n.cmp(&a.n).then(a.reason.cmp(&b.reason)));
997    for c in &mut counts {
998        c.text = reason_count_words(c);
999    }
1000    Some(speccoder_core::Unverified {
1001        ticks,
1002        regression_only: regression,
1003        counts,
1004    })
1005}
1006
1007/// `7 had no user story`, `7 were already passing — implemented by T003`.
1008fn reason_count_words(c: &speccoder_core::ReasonCount) -> String {
1009    let n = c.n;
1010    let were = if n == 1 { "was" } else { "were" };
1011    let what = match c.reason.as_str() {
1012        "no_story" => "had no user story".to_owned(),
1013        "no_scenarios" => "had a story with no acceptance scenario".to_owned(),
1014        "acceptance_off" => format!("{were} ticked with acceptance tests off"),
1015        "author_wrote_none" => "got no test from the test author".to_owned(),
1016        "author_failed" => "had acceptance tests that could not be used".to_owned(),
1017        "already_passing" if c.implemented_by.is_empty() => format!("{were} already passing"),
1018        "already_passing" => format!(
1019            "{were} already passing — implemented by {}",
1020            c.implemented_by.join(", ")
1021        ),
1022        "not_red" => "had acceptance tests that did not fail first".to_owned(),
1023        "tests_not_collected" => "had acceptance tests that never ran".to_owned(),
1024        "planning_work" => format!("{were} planning work (design documents)"),
1025        _ => format!("{were} ticked before reasons were recorded"),
1026    };
1027    format!("{n} {what}")
1028}
1029
1030/// The feature was handed off: its speccoder branch was pushed, or it is
1031/// merged with every task done.
1032fn handed_off(r: &StatusReport, path: &Path) -> bool {
1033    let merged = !r.tasks.is_empty()
1034        && r.totals.ready == 0
1035        && r.totals.blocked == 0
1036        && r.unmerged_commits == 0
1037        && r.branch.is_some();
1038    let pushed = r.branch.as_deref().is_some_and(|b| {
1039        crate::git::Git::at(path)
1040            .line(&[
1041                "for-each-ref",
1042                "--format=%(refname)",
1043                &format!("refs/remotes/*/{b}"),
1044            ])
1045            .is_ok_and(|l| !l.trim().is_empty())
1046    });
1047    merged || pushed
1048}
1049
1050/// Every task finished and the branch carries work not merged.
1051fn handoff(r: &StatusReport) -> bool {
1052    !r.tasks.is_empty()
1053        && r.totals.ready == 0
1054        && r.totals.blocked == 0
1055        && r.unmerged_commits > 0
1056        && r.branch.is_some()
1057}
1058
1059fn with_input(
1060    t: &speccoder_core::TaskStatus,
1061    verb: &str,
1062    why: &str,
1063    prompt: &str,
1064    run: Runnable,
1065    flag: &dyn Fn(String) -> String,
1066) -> Action {
1067    let option = if verb == "nudge" {
1068        "--note"
1069    } else {
1070        "--reason"
1071    };
1072    let mut a = action(
1073        "task",
1074        format!("{verb} {}", t.id),
1075        why,
1076        flag(format!("speccoder task {verb} {} {option} \"…\"", t.id)),
1077    );
1078    a = a.with_run(run);
1079    a.input = Some(prompt.to_owned());
1080    a
1081}
1082
1083/// The Needs You item of a bug that stopped for the person, if it did.
1084fn bug_item(
1085    b: &speccoder_core::bug::BugReport,
1086    flag: &dyn Fn(String) -> String,
1087) -> Option<InboxItem> {
1088    use speccoder_core::bug::Outcome;
1089    let key = b.key();
1090    let kind = match b.outcome {
1091        Outcome::AwaitingConfirmation => Kind::BugConfirm,
1092        Outcome::CannotReproduce => Kind::BugCannotReproduce,
1093        Outcome::UnchangedNotHolding => Kind::BugUnchangedNotHolding,
1094        // Kept as a regression test: decided.
1095        Outcome::AlreadySatisfied if b.commit.is_some() => return None,
1096        Outcome::AlreadySatisfied => Kind::BugAlreadySatisfied,
1097        _ if b.code.as_deref() == Some("BUG-REPRO-WEAK") => Kind::BugReproWeak,
1098        _ => return None,
1099    };
1100    let task = || b.task.clone();
1101    let mut actions = Vec::new();
1102    match kind {
1103        Kind::BugConfirm => {
1104            let digest = b.confirm_digest.clone().unwrap_or_default();
1105            let mut yes = action(
1106                "next",
1107                format!("confirm {}'s reproductions", b.task),
1108                "they show the bug: they are frozen and the fix runs",
1109                format!("speccoder bug confirm {key} --digest {digest}"),
1110            );
1111            yes = yes.with_run(Runnable::BugConfirm {
1112                task: key.clone(),
1113                digest: digest.clone(),
1114                reject: false,
1115            });
1116            let mut no = action(
1117                "next",
1118                format!("reject {}'s reproductions", b.task),
1119                "the author tries again, told why",
1120                format!("speccoder bug confirm {key} --reject --reason \"…\""),
1121            );
1122            no = no.with_run(Runnable::BugConfirm {
1123                task: key.clone(),
1124                digest,
1125                reject: true,
1126            });
1127            no.input = Some("Why they do not show the bug".to_owned());
1128            actions.extend([yes, no]);
1129        }
1130        Kind::BugAlreadySatisfied => {
1131            let mut keep = action(
1132                "next",
1133                format!("keep {}'s reproduction as a regression test", b.task),
1134                "the reproduction is committed and the task accepted on your word: attested, not verified",
1135                format!("speccoder bug keep {key} --reason \"kept as a regression test\""),
1136            );
1137            keep = keep.with_run(Runnable::BugKeep { task: key.clone() });
1138            keep.input = Some("Why it is kept".to_owned());
1139            let mut drop = action(
1140                "task",
1141                format!("drop {}", b.task),
1142                "it is fixed already",
1143                flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1144            );
1145            drop = drop.with_run(Runnable::Skip { task: task() });
1146            drop.input = Some("Why it is dropped".to_owned());
1147            actions.extend([keep, drop]);
1148        }
1149        Kind::BugReproWeak => {
1150            let mut rule = action(
1151                "task",
1152                format!("rule on {}", b.task),
1153                "the tests are right: the special case is not a fix",
1154                flag(format!(
1155                    "speccoder task rule {} --tests-right --reason \"…\"",
1156                    b.task
1157                )),
1158            );
1159            rule = rule.with_run(Runnable::Rule { task: task() });
1160            rule.input = Some("Why the tests are right".to_owned());
1161            actions.push(rule);
1162            actions.push(action(
1163                "task",
1164                "bring your own test",
1165                "one per Expected statement",
1166                format!(
1167                    "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1168                    b.triad.current.text
1169                ),
1170            ));
1171        }
1172        _ => {
1173            let mut again = action(
1174                "next",
1175                format!("reproduce {} again", b.task),
1176                "a fresh set of tries",
1177                format!("speccoder bug run --task {key}"),
1178            );
1179            again = again.with_run(Runnable::BugRun {
1180                slug: None,
1181                task: Some(key.clone()),
1182            });
1183            actions.push(again);
1184            actions.push(action(
1185                "task",
1186                "bring your own test",
1187                "one per Expected statement",
1188                format!(
1189                    "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1190                    b.triad.current.text
1191                ),
1192            ));
1193            if !b.home.kind.native() {
1194                actions.push(action(
1195                    "task",
1196                    format!("reword {}", b.requirement),
1197                    "the triad in other words (what was confirmed lapses)",
1198                    format!(
1199                        "speccoder review edit {} \"…\" --feature {}",
1200                        b.requirement, b.unit
1201                    ),
1202                ));
1203                let mut drop = action(
1204                    "task",
1205                    format!("drop {}", b.task),
1206                    "nothing is fixed",
1207                    flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1208                );
1209                drop = drop.with_run(Runnable::Skip { task: task() });
1210                drop.input = Some("Why it is dropped".to_owned());
1211                actions.push(drop);
1212            }
1213        }
1214    }
1215    let detail = b
1216        .handoff
1217        .as_ref()
1218        .map(|h| {
1219            format!(
1220                "{} tries; reproduced: {}; not reproduced: {}; already passing: {}",
1221                h.tries.len(),
1222                if h.statements.reproduced.is_empty() {
1223                    "none".to_owned()
1224                } else {
1225                    h.statements.reproduced.join(", ")
1226                },
1227                if h.statements.not_reproduced.is_empty() {
1228                    "none".to_owned()
1229                } else {
1230                    h.statements.not_reproduced.join(", ")
1231                },
1232                if h.statements.satisfied.is_empty() {
1233                    "none".to_owned()
1234                } else {
1235                    h.statements.satisfied.join(", ")
1236                },
1237            )
1238        })
1239        .unwrap_or_else(|| format!("{} — {}", b.title, b.outcome.as_str().replace('_', " ")));
1240    Some(InboxItem {
1241        unverified: None,
1242        kind: kind.as_str().to_owned(),
1243        group: kind.group().as_str().to_owned(),
1244        feature: b.unit.clone(),
1245        task: Some(b.task.clone()),
1246        title: match kind {
1247            Kind::BugConfirm => format!(
1248                "{}: {} reproduction(s) wait for your confirmation",
1249                b.task,
1250                b.reproductions.iter().filter(|r| r.reproduces()).count()
1251            ),
1252            Kind::BugAlreadySatisfied => format!(
1253                "{}: already fixed — keep the reproduction or drop it",
1254                b.task
1255            ),
1256            Kind::BugReproWeak => {
1257                format!("{}: a special-case fix passes the reproductions", b.task)
1258            }
1259            Kind::BugUnchangedNotHolding => {
1260                format!("{}: an Unchanged statement does not hold today", b.task)
1261            }
1262            _ => format!("{}: could not be reproduced", b.task),
1263        },
1264        detail,
1265        since: b.confirmation.as_ref().map(|c| c.at),
1266        actions,
1267    })
1268}
1269
1270/// Why a new attempt cannot start now: planning files a run cannot read
1271/// yet, or a waiting call's attempt that holds the worktree.
1272fn retry_blocked(r: &StatusReport, waiting: &[&str]) -> Option<String> {
1273    if !r.uncommitted.is_empty() {
1274        Some("commit the planning files first".to_owned())
1275    } else {
1276        waiting
1277            .first()
1278            .map(|w| format!("{w} waits for your decision and holds the worktree: decide it first"))
1279    }
1280}
1281
1282/// The open tasks that wait on `id`, directly or through another.
1283fn dependents(tasks: &[speccoder_core::TaskStatus], id: &str) -> Vec<String> {
1284    let mut waiting: Vec<&str> = vec![id];
1285    loop {
1286        let more: Vec<&str> = tasks
1287            .iter()
1288            .filter(|t| !waiting.contains(&t.id.as_str()))
1289            .filter(|t| t.blocked_by.iter().any(|b| waiting.contains(&b.as_str())))
1290            .map(|t| t.id.as_str())
1291            .collect();
1292        if more.is_empty() {
1293            break;
1294        }
1295        waiting.extend(more);
1296    }
1297    tasks
1298        .iter()
1299        .filter(|t| t.id != id && waiting.contains(&t.id.as_str()))
1300        .map(|t| t.id.clone())
1301        .collect()
1302}
1303
1304/// I did it, Hint & retry, Skip: the three ways on from a task that needs
1305/// the person.
1306fn yours_actions(
1307    t: &speccoder_core::TaskStatus,
1308    waiting: &[String],
1309    retry: Option<String>,
1310    flag: &dyn Fn(String) -> String,
1311) -> Vec<Action> {
1312    let mut done = action(
1313        "task",
1314        format!("I did it: {}", t.id),
1315        "tick it on your word (attested, never shown as verified)",
1316        flag(format!("speccoder task done {}", t.id)),
1317    );
1318    done = done.with_run(Runnable::Done { task: t.id.clone() });
1319    let mut hint = action(
1320        "task",
1321        format!("hint & retry {}", t.id),
1322        "a note the agent reads, then a new attempt now",
1323        flag(format!("speccoder task hint {} \"…\"", t.id)),
1324    );
1325    hint = hint.with_run(Runnable::Hint { task: t.id.clone() });
1326    hint.input = Some("The hint".to_owned());
1327    hint.blocked = retry;
1328    let mut skip = skip(t, flag);
1329    if !waiting.is_empty() {
1330        let stay = if waiting.len() == 1 { "stays" } else { "stay" };
1331        skip.why = format!("runs pass it by; {} {stay} blocked", waiting.join(", "));
1332    }
1333    vec![done, hint, skip]
1334}
1335
1336fn nudge(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1337    with_input(
1338        t,
1339        "nudge",
1340        "a note its next attempts read",
1341        "The note",
1342        Runnable::Nudge { task: t.id.clone() },
1343        flag,
1344    )
1345}
1346
1347fn skip(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1348    with_input(
1349        t,
1350        "skip",
1351        "runs pass it by; what depends on it stays blocked",
1352        "Why it is skipped",
1353        Runnable::Skip { task: t.id.clone() },
1354        flag,
1355    )
1356}
1357
1358fn accept(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1359    with_input(
1360        t,
1361        "accept",
1362        "tick it on your word (attested, never shown as verified)",
1363        "Why it is done",
1364        Runnable::Accept { task: t.id.clone() },
1365        flag,
1366    )
1367}
1368
1369/// The same task with the next agent that can start here: one attempt,
1370/// judged as every attempt is. Offered after an external agent's attempt
1371/// (its run is `external_<agent>_…`), never after a native one.
1372fn try_with(
1373    task: &str,
1374    last: &speccoder_core::LastAttempt,
1375    flag: &dyn Fn(String) -> String,
1376    on_path: impl Fn(&str) -> bool,
1377) -> Option<Action> {
1378    let current = last
1379        .run_id
1380        .as_deref()
1381        .and_then(|r| r.strip_prefix("external_"))
1382        .and_then(|r| r.split('_').next())
1383        .filter(|a| *a != "command")?;
1384    let next = crate::acp::ready::next_after(Some(current), &on_path)?;
1385    Some(
1386        action(
1387            "work",
1388            format!("try {task} with {}", next.name),
1389            "the same task, another agent: one attempt, judged the same way",
1390            flag(format!("speccoder run --task {task} --agent {}", next.id)),
1391        )
1392        .with_run(Runnable::Run {
1393            task: Some(task.to_owned()),
1394            agent: Some(next.id.to_owned()),
1395        }),
1396    )
1397}
1398
1399#[cfg(test)]
1400mod try_with_tests {
1401    use super::*;
1402
1403    #[test]
1404    fn a_failed_agent_attempt_offers_the_next_agent_and_a_native_one_does_not() {
1405        let t = "T005";
1406        let last = |run: &str| speccoder_core::LastAttempt {
1407            outcome: "verification_failed".to_owned(),
1408            reason: String::new(),
1409            run_id: Some(run.to_owned()),
1410            reference: String::new(),
1411        };
1412        let flag = |c: String| c;
1413        let both = |x: &str| x == "copilot" || x == "opencode";
1414        let a = try_with(t, &last("external_copilot_1"), &flag, both)
1415            .unwrap_or_else(|| panic!("offered"));
1416        assert_eq!(a.command, "speccoder run --task T005 --agent opencode");
1417        assert_eq!(
1418            a.run,
1419            Some(Runnable::Run {
1420                task: Some("T005".to_owned()),
1421                agent: Some("opencode".to_owned())
1422            })
1423        );
1424        assert!(try_with(t, &last("run_01ABC"), &flag, both).is_none());
1425        assert!(try_with(t, &last("external_command_1"), &flag, both).is_none());
1426        let alone = |x: &str| x == "copilot";
1427        assert!(try_with(t, &last("external_copilot_1"), &flag, alone).is_none());
1428    }
1429}