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