Skip to main content

speccoder_runtime/
inbox.rs

1//! `speccoder inbox`: what waits for a person across the project's
2//! features — blocked on them (oldest first), ready to review (riskiest
3//! evidence first), working, idle. Read-only; every item carries the
4//! actions that decide it.
5
6use std::path::Path;
7
8use speccoder_core::actions::{Action, Runnable};
9use speccoder_core::{
10    ApprovalsReport, CommandData, CommandResult, CommandStatus, InboxItem, InboxReport,
11    StatusReport,
12};
13
14use crate::actions::action;
15
16/// The kinds, most urgent first: what blocks a run, what questions the
17/// evidence, what stops a task, what blocks a hand-off — derived from the
18/// one registry, `speccoder_core::inbox::Kind`.
19pub const KINDS: [&str; Kind::ALL.len()] = {
20    let mut out = [""; Kind::ALL.len()];
21    let mut i = 0;
22    while i < out.len() {
23        out[i] = Kind::ALL[i].as_str();
24        i += 1;
25    }
26    out
27};
28
29use speccoder_core::inbox::Kind;
30
31/// `speccoder inbox [--feature]`: the decisions waiting in every feature of
32/// the project at `path`, or in `feature` only.
33#[must_use]
34pub fn inbox(path: &Path, feature: Option<&str>) -> CommandResult {
35    inbox_with(path, feature, crate::worktree::state_dir().as_deref())
36}
37
38/// [`inbox`] with an explicit state directory (where waiting calls and
39/// held attempts are kept).
40#[must_use]
41pub fn inbox_with(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
42    let listed = crate::current::list(path, None);
43    let Some(CommandData::Features(f)) = listed.data() else {
44        return CommandResult::carried("inbox", &listed);
45    };
46    // `--feature` takes a name or a number, as everywhere: the one it selects.
47    let only = match feature {
48        Some(_) => match crate::current::list(path, feature).data() {
49            Some(CommandData::Features(one)) => one.current.clone(),
50            _ => None,
51        },
52        None => None,
53    };
54    if feature.is_some() && only.is_none() {
55        return CommandResult::carried("inbox", &crate::status::report(path, feature));
56    }
57    let names: Vec<String> = f
58        .features
59        .iter()
60        .map(|e| e.name.clone())
61        .filter(|n| only.as_ref().is_none_or(|o| o == n))
62        .collect();
63    let several = names.len() > 1;
64    let mut items = Vec::new();
65    for name in &names {
66        items.extend(of_feature(path, name, state, several));
67    }
68    // Bugs of the format's own bug structure: no feature lists them.
69    if only.is_none()
70        && let Some(s) = state
71        && let Some(project) = crate::format::at(path).detect(path)
72    {
73        let plane = crate::worktree::project_state_dir(s, &project);
74        items.extend(
75            crate::bug::state::all(&plane)
76                .iter()
77                .filter(|b| b.home.kind.native())
78                // A bug the person dropped waits for nobody.
79                .filter(|b| !crate::controls::skipped(&project, &b.unit).contains_key(&b.task))
80                .filter_map(|b| bug_item(b, &|c| c)),
81        );
82    }
83    sort(&mut items);
84    let working = working(path, state);
85    let idle: Vec<String> = names
86        .iter()
87        .filter(|n| !items.iter().any(|i| &i.feature == *n))
88        .filter(|n| !working.iter().any(|w| &w.feature == *n))
89        .cloned()
90        .collect();
91    let groups = speccoder_core::inbox::Group::ALL
92        .iter()
93        .map(|g| speccoder_core::GroupCount {
94            group: g.as_str().to_owned(),
95            title: g.title().to_owned(),
96            count: match g {
97                speccoder_core::inbox::Group::Working => working.len(),
98                speccoder_core::inbox::Group::Idle => idle.len(),
99                _ => items.iter().filter(|i| i.group == g.as_str()).count(),
100            },
101        })
102        .collect();
103    let message = summary(&items, names.len());
104    CommandResult::new("inbox", CommandStatus::Ok, &message).with_data(CommandData::Inbox(
105        Box::new(InboxReport {
106            items,
107            features: names.len(),
108            groups,
109            working,
110            idle,
111        }),
112    ))
113}
114
115/// Needs You's order: *Blocked on you* first — a journal that could not be
116/// read, then the oldest, then the kind's urgency — then *Ready to review*,
117/// the riskiest first.
118fn sort(items: &mut [InboxItem]) {
119    let rank = |k: &str| KINDS.iter().position(|x| *x == k).unwrap_or(KINDS.len());
120    let blocked = |i: &InboxItem| i.group != "ready_to_review";
121    items.sort_by(|a, b| {
122        let by_group = blocked(b).cmp(&blocked(a));
123        let within = if blocked(a) {
124            (a.kind != "unknown")
125                .cmp(&(b.kind != "unknown"))
126                .then(
127                    a.since
128                        .unwrap_or(u64::MAX)
129                        .cmp(&b.since.unwrap_or(u64::MAX)),
130                )
131                .then(rank(&a.kind).cmp(&rank(&b.kind)))
132        } else {
133            review_rank(a)
134                .cmp(&review_rank(b))
135                .then(rank(&a.kind).cmp(&rank(&b.kind)))
136                .then(
137                    a.since
138                        .unwrap_or(u64::MAX)
139                        .cmp(&b.since.unwrap_or(u64::MAX)),
140                )
141        };
142        by_group
143            .then(within)
144            .then(a.feature.cmp(&b.feature))
145            .then(a.task.cmp(&b.task))
146    });
147}
148
149/// The project's run in progress, or one whose process is gone before it
150/// finished (its state unknown, never idle).
151fn working(path: &Path, state: Option<&Path>) -> Vec<speccoder_core::WorkingRun> {
152    let Some((live, running)) = state.and_then(|s| crate::progress::read_live(path, s)) else {
153        return Vec::new();
154    };
155    if live.finished.is_some() {
156        return Vec::new();
157    }
158    vec![speccoder_core::WorkingRun {
159        feature: live.feature,
160        task: (!live.task.is_empty()).then_some(live.task),
161        started: live.started_at,
162        pid: live.pid,
163        state: if running { "running" } else { "unknown" }.to_owned(),
164    }]
165}
166
167/// `n` with the word for one or for many: `1 tick`, `2 ticks`.
168fn count(n: usize, one: &str, many: &str) -> String {
169    format!("{n} {}", if n == 1 { one } else { many })
170}
171
172/// A kind counted in words: `2 approvals`, `1 open question`.
173fn counted(kind: &str, n: usize) -> String {
174    let (one, many) = match kind {
175        "unknown" => (
176            "feature whose waiting calls could not be read",
177            "features whose waiting calls could not be read",
178        ),
179        "approval" => ("approval", "approvals"),
180        "approval_batch" => (
181            "run whose calls wait together",
182            "runs whose calls wait together",
183        ),
184        "ruling" => ("ruling", "rulings"),
185        "gaming" => ("suspected shortcut", "suspected shortcuts"),
186        "unverified" => (
187            "feature little was verified in",
188            "features little was verified in",
189        ),
190        "stale" => (
191            "tick the spec changed under",
192            "ticks the spec changed under",
193        ),
194        "question" => ("open question", "open questions"),
195        "ambiguous" => ("scenario read two ways", "scenarios read two ways"),
196        "autopilot_denied" => ("call autopilot refused", "calls autopilot refused"),
197        "review" => ("spec to review", "specs to review"),
198        "revise" => ("change request", "change requests"),
199        "stuck" => ("task that did not pass", "tasks that did not pass"),
200        "gate_blocked" => ("tick a gate blocked", "ticks a gate blocked"),
201        "hook_untrusted" => (
202            "gate that waits for your trust",
203            "gates that wait for your trust",
204        ),
205        "yours" => ("step only you can take", "steps only you can take"),
206        "aside" => ("set-aside change", "set-aside changes"),
207        "commit" => ("plan to commit", "plans to commit"),
208        "handoff" => ("branch to hand off", "branches to hand off"),
209        "promote" => ("change that grew", "changes that grew"),
210        "bug_confirm" => (
211            "bug reproduction to confirm",
212            "bug reproductions to confirm",
213        ),
214        "bug_cannot_reproduce" => (
215            "bug that could not be reproduced",
216            "bugs that could not be reproduced",
217        ),
218        "bug_unchanged_not_holding" => (
219            "bug whose Unchanged statement does not hold",
220            "bugs whose Unchanged statements do not hold",
221        ),
222        "bug_repro_weak" => ("bug reproduction too weak", "bug reproductions too weak"),
223        "bug_already_satisfied" => ("bug already fixed", "bugs already fixed"),
224        k => (k, k),
225    };
226    count(n, one, many)
227}
228
229/// `3 decisions waiting in 2 features: 1 approval, 2 tasks that did not pass`.
230fn summary(items: &[InboxItem], features: usize) -> String {
231    if items.is_empty() {
232        return format!(
233            "nothing waits for you in {}: the agents can work",
234            count(features, "feature", "features")
235        );
236    }
237    let mut in_features: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
238    in_features.sort_unstable();
239    in_features.dedup();
240    let parts: Vec<String> = KINDS
241        .iter()
242        .filter_map(|k| {
243            let n = items.iter().filter(|i| i.kind == *k).count();
244            (n > 0).then(|| counted(k, n))
245        })
246        .collect();
247    format!(
248        "{} waiting in {}: {}",
249        count(items.len(), "decision", "decisions"),
250        count(in_features.len(), "feature", "features"),
251        parts.join(", ")
252    )
253}
254
255/// One feature's items. `several`: commands name the feature.
256fn of_feature(path: &Path, feature: &str, state: Option<&Path>, several: bool) -> Vec<InboxItem> {
257    let report = crate::status::report(path, Some(feature));
258    let Some(CommandData::Report(r)) = report.data() else {
259        return Vec::new();
260    };
261    let approvals: Option<ApprovalsReport> =
262        state.and_then(
263            |s| match crate::approvals::list_with(path, Some(feature), s).data() {
264                Some(CommandData::Approvals(a)) => Some((**a).clone()),
265                _ => None,
266            },
267        );
268    let flag = |command: String| {
269        if several {
270            format!("{command} --feature {feature}")
271        } else {
272            command
273        }
274    };
275    let item = |kind: &str, task: Option<&str>, title: String, detail: String, actions| InboxItem {
276        kind: kind.to_owned(),
277        group: Kind::named(kind)
278            .map_or("blocked_on_you", |k| k.group().as_str())
279            .to_owned(),
280        feature: feature.to_owned(),
281        task: task.map(str::to_owned),
282        title,
283        detail,
284        since: None,
285        actions,
286        unverified: None,
287        risk: Vec::new(),
288        tool: None,
289    };
290    let mut out = Vec::new();
291    let waiting: Vec<&str> = approvals
292        .iter()
293        .flat_map(|a| &a.pending)
294        .map(|p| p.task.as_str())
295        .collect();
296
297    // The journal could not be read: what waits there is unknown, and the
298    // person is told so rather than shown an empty list.
299    if let Some(why) = approvals.as_ref().and_then(|a| a.unreadable.as_ref()) {
300        out.push(item(
301            "unknown",
302            None,
303            format!("Calls waiting in {feature} could not be read"),
304            format!("{why} — some may be waiting for you"),
305            Vec::new(),
306        ));
307    }
308
309    // A run that used its asking budget: its waiting calls are one item,
310    // decided together.
311    let batched_runs: Vec<String> = approvals
312        .iter()
313        .flat_map(|a| &a.batch)
314        .map(|b| b.run.clone())
315        .collect();
316    for b in approvals.iter().flat_map(|a| &a.batch) {
317        let calls: Vec<String> = b.groups.iter().flat_map(|g| g.calls.clone()).collect();
318        let of_run: Vec<&speccoder_core::PendingCall> = approvals
319            .iter()
320            .flat_map(|a| &a.pending)
321            .filter(|p| p.run.as_deref() == Some(b.run.as_str()))
322            .collect();
323        let detail: Vec<String> = b
324            .groups
325            .iter()
326            .map(|g| {
327                let shown: Vec<String> = of_run
328                    .iter()
329                    .filter(|p| p.object.as_ref().map_or("write", |o| o.class.as_str()) == g.class)
330                    .map(|p| {
331                        let what = p
332                            .object
333                            .as_ref()
334                            .map_or(p.covers.clone(), |o| o.short.clone());
335                        let risk = p.presentation.as_ref().map_or("", |v| v.risk.as_str());
336                        let copied = if p.sources.is_empty() {
337                            ""
338                        } else {
339                            ", copied from content"
340                        };
341                        format!("{what} ({}, {risk}{copied})", p.task)
342                    })
343                    .collect();
344                format!("{}: {}", g.class, shown.join("; "))
345            })
346            .collect();
347        let decide = |verdict, resume_asking| Runnable::DecideBatch {
348            run: b.run.clone(),
349            verdict,
350            calls: calls.clone(),
351            digest: b.digest.clone(),
352            resume_asking,
353        };
354        let command = |verb: &str| {
355            flag(format!(
356                "speccoder approvals batch {} {verb} --digest {}",
357                b.run, b.digest
358            ))
359        };
360        let yes = action(
361            "next",
362            format!("approve the {} waiting call(s) once", calls.len()),
363            "each listed call runs once; the run keeps waiting at its next call",
364            command("approve"),
365        )
366        .with_run(decide(
367            speccoder_core::actions::BatchVerdict::Approve,
368            false,
369        ));
370        let again = action(
371            "next",
372            "approve once and resume asking",
373            "each listed call runs once, and the run prompts you again",
374            format!("{} --resume-asking", command("approve")),
375        )
376        .with_run(decide(speccoder_core::actions::BatchVerdict::Approve, true));
377        let mut no = action(
378            "next",
379            format!("reject the {} waiting call(s)", calls.len()),
380            "the model is told your reason for each",
381            format!("{} --reason \"…\"", command("reject")),
382        )
383        .with_run(decide(speccoder_core::actions::BatchVerdict::Reject, false));
384        no.input = Some("Why (the model is told)".to_owned());
385        let mut i = item(
386            "approval_batch",
387            None,
388            format!(
389                "{} call(s) wait for you in run {} (its approval budget is used)",
390                calls.len(),
391                b.run
392            ),
393            detail.join(" · "),
394            vec![yes, again, no],
395        );
396        i.since = of_run.iter().filter_map(|p| p.waiting_since).min();
397        out.push(i);
398    }
399
400    // A run waits on these.
401    for p in approvals
402        .iter()
403        .flat_map(|a| &a.pending)
404        .filter(|p| p.run.as_ref().is_none_or(|r| !batched_runs.contains(r)))
405    {
406        let question = p.kind == "question";
407        let mut yes = action(
408            "next",
409            if question {
410                format!("answer {}", p.task)
411            } else {
412                format!("approve {}", p.task)
413            },
414            if question {
415                "the attempt resumes with your answer".to_owned()
416            } else {
417                format!("allow {} for the task", p.covers)
418            },
419            flag(format!("speccoder task approve {}", p.task)),
420        );
421        yes = yes.with_run(Runnable::Approve {
422            task: p.task.clone(),
423            call: p.id(),
424            digest: p.digest.clone(),
425        });
426        if question {
427            yes.input = Some("Your answer".to_owned());
428        }
429        let mut no = action(
430            "next",
431            format!("reject {}", p.task),
432            "the model is told your reason",
433            flag(format!("speccoder task reject {} --reason \"…\"", p.task)),
434        );
435        no = no.with_run(Runnable::Reject {
436            task: p.task.clone(),
437            call: p.id(),
438            digest: p.digest.clone(),
439        });
440        no.input = Some("Why (the model is told)".to_owned());
441        let mut i = item(
442            "approval",
443            Some(&p.task),
444            if question {
445                format!("{} asks you", p.task)
446            } else {
447                format!("{} waits for your approval", p.task)
448            },
449            p.question
450                .clone()
451                .filter(|_| question)
452                .unwrap_or_else(|| format!("{} — {}", p.covers, p.reason)),
453            vec![yes, no],
454        );
455        i.since = p.waiting_since;
456        i.tool = (!question).then(|| p.tool.clone());
457        out.push(i);
458    }
459
460    let root = Path::new(path);
461    let project_state = state.and_then(|s| {
462        crate::format::at(root)
463            .detect(root)
464            .map(|project| crate::worktree::project_state_dir(s, &project))
465    });
466    for t in &r.tasks {
467        let done = matches!(
468            t.state.as_str(),
469            "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
470        );
471        if done || waiting.contains(&t.id.as_str()) {
472            continue;
473        }
474        if let Some(i) = project_state
475            .as_deref()
476            .and_then(|s| crate::bug::state::load(s, feature, &t.id))
477            .and_then(|b| bug_item(&b, &flag))
478        {
479            out.push(i);
480            continue;
481        }
482        let held = project_state
483            .as_deref()
484            .and_then(|s| crate::strength::held(&crate::strength::dir(s, feature, &t.id)));
485        if let Some(held) = held {
486            let mut rule = action(
487                "task",
488                format!("rule on {}", t.id),
489                "the tests are right: the flagged alternatives are not what the spec allows",
490                flag(format!(
491                    "speccoder task rule {} --tests-right --reason \"…\"",
492                    t.id
493                )),
494            );
495            rule = rule.with_run(Runnable::Rule { task: t.id.clone() });
496            rule.input = Some("Why the tests are right".to_owned());
497            let findings: Vec<String> = held
498                .findings
499                .iter()
500                .map(|p| format!("{} ({})", p.scenario, p.rationale))
501                .collect();
502            out.push(item(
503                "ruling",
504                Some(&t.id),
505                format!("{}'s tests reject an alternative the spec may allow", t.id),
506                findings.join("; "),
507                vec![rule, nudge(t, &flag), skip(t, &flag)],
508            ));
509            continue;
510        }
511        // A person may rule tests wrong: a red scenario's at the story's
512        // checkpoint (nothing else of the oracle), or the task's own test
513        // outside a story.
514        let mut rulings: Vec<_> = t
515            .last_attempt
516            .iter()
517            .flat_map(|a| a.story_open.iter())
518            .map(|id| {
519                let mut a = action(
520                    "decide",
521                    format!("rule {id}'s tests wrong"),
522                    format!("when {id}'s tests assert what the spec does not say: the next run writes them again"),
523                    flag(format!(
524                        "speccoder task rule {} --tests-wrong --scenario {id} --reason \"…\"",
525                        t.id
526                    )),
527                )
528                .with_run(Runnable::RuleScenario {
529                    task: t.id.clone(),
530                    scenario: Some(id.clone()),
531                });
532                a.input = Some(format!("What {id}'s tests assert that the spec does not say"));
533                a
534            })
535            .collect();
536        // Its own test (outside a story) failed, or was called wrong.
537        if let Some(path) = t.last_attempt.as_ref().and_then(|a| a.own_test.as_ref()) {
538            let mut a = action(
539                "decide",
540                "these tests are wrong".to_owned(),
541                format!("when `{path}` asserts what the spec does not say: the next run writes it again, your reason told to its author"),
542                flag(format!(
543                    "speccoder task rule {} --tests-wrong --reason \"…\"",
544                    t.id
545                )),
546            )
547            .with_run(Runnable::RuleScenario {
548                task: t.id.clone(),
549                scenario: None,
550            });
551            a.input = Some(format!("What `{path}` asserts that the spec does not say"));
552            rulings.push(a);
553        }
554        // A step only a person can take — a `[YOU]` task that is ready, or
555        // an honest stop: the agent's reason as its claim, and three ways on.
556        if t.lane == speccoder_core::lane::Lane::Yours
557            && t.last_attempt
558                .as_ref()
559                .map_or(t.state == "ready", |a| a.outcome == "blocked")
560        {
561            let (title, detail) = match &t.last_attempt {
562                Some(a) => (
563                    format!("{} needs you: the agent stopped", t.id),
564                    a.reason.clone(),
565                ),
566                // Marked `[YOU]`: no claim to show.
567                None => (format!("{} is yours: {}", t.id, t.title), String::new()),
568            };
569            out.push(item(
570                "yours",
571                Some(&t.id),
572                title,
573                detail,
574                rulings
575                    .clone()
576                    .into_iter()
577                    .chain(yours_actions(
578                        t,
579                        &dependents(&r.tasks, &t.id),
580                        retry_blocked(r, &waiting),
581                        &flag,
582                    ))
583                    .collect(),
584            ));
585            continue;
586        }
587        let Some(a) = &t.last_attempt else {
588            continue;
589        };
590        // A gate the person declared stopped the tick: its own words, the
591        // way to trust it when that is why, and the way back to a tick.
592        if a.outcome == "blocked_by_gate" {
593            let (kind, title, detail, actions) = gate_item(path, state, t, a, &flag);
594            out.push(item(kind, Some(&t.id), title, detail, actions));
595            continue;
596        }
597        if a.outcome == "suspected_gaming" {
598            out.push(item(
599                "gaming",
600                Some(&t.id),
601                format!("{}: the monitor suspects a shortcut", t.id),
602                a.reason.clone(),
603                vec![nudge(t, &flag), skip(t, &flag), accept(t, &flag)],
604            ));
605            continue;
606        }
607        let mut actions: Vec<_> = rulings;
608        if t.state == "ready" {
609            let mut again = action(
610                "work",
611                format!("run {} again", t.id),
612                "with what the last attempt left: its facts and your notes",
613                flag(format!("speccoder run --task {}", t.id)),
614            );
615            again = again.with_run(Runnable::Run {
616                task: Some(t.id.clone()),
617                agent: None,
618                cap: None,
619            });
620            again.blocked = retry_blocked(r, &waiting);
621            let other = try_with(&t.id, a, &flag, crate::acp::agent::on_path);
622            actions.push(again);
623            if let Some(mut other) = other {
624                other.blocked = retry_blocked(r, &waiting);
625                actions.push(other);
626            }
627        }
628        actions.extend([nudge(t, &flag), skip(t, &flag), accept(t, &flag)]);
629        out.push(item(
630            "stuck",
631            Some(&t.id),
632            format!("{}'s last attempt: {}", t.id, a.outcome.replace('_', " ")),
633            match &a.own_test {
634                Some(path) => format!("Its own test `{path}` failed. {}", a.reason),
635                None => a.reason.clone(),
636            },
637            actions,
638        ));
639    }
640
641    // Changes a run found and set aside: kept on the branch, or let go.
642    for t in &r.tasks {
643        let Some(a) = &t.set_aside else { continue };
644        let n = a.files.len();
645        let mut keep = action(
646            "task",
647            format!("keep the changes set aside before {}", t.id),
648            "committed onto the feature's branch as yours",
649            flag(format!("speccoder task restore {}", t.id)),
650        );
651        keep = keep.with_run(Runnable::KeepSetAside { task: t.id.clone() });
652        let mut drop = action(
653            "task",
654            format!("discard the changes set aside before {}", t.id),
655            "deleted; the branch stays as it is",
656            flag(format!("speccoder task drop {}", t.id)),
657        );
658        drop = drop.with_run(Runnable::DropSetAside { task: t.id.clone() });
659        drop.confirm = Some(format!(
660            "Discard the {n} changed file(s) set aside before {}? They are deleted, not kept anywhere.",
661            t.id
662        ));
663        out.push(item(
664            "aside",
665            Some(&t.id),
666            format!("Changes were set aside before {} ran", t.id),
667            format!(
668                "{n} file(s) were changed in the worktree when the run started — your edits, or an interrupted run's: {}",
669                a.files.iter().take(4).cloned().collect::<Vec<_>>().join(", ")
670                    + if n > 4 { ", …" } else { "" }
671            ),
672            vec![keep, drop],
673        ));
674    }
675
676    // A quick change or bug that grew: one offer per task, on recorded
677    // facts (never a model's opinion), never a notification.
678    if let Some(state) = state {
679        let plane = crate::worktree::project_state_dir(state, path);
680        for o in crate::ladder::offers(path, &plane, feature) {
681            out.push(promote_item(&plane, &o, |t, d, a| {
682                item("promote", Some(&o.task), t, d, a)
683            }));
684        }
685    }
686
687    let stale: Vec<String> = r
688        .tasks
689        .iter()
690        .filter_map(|t| {
691            let d = t.drift.as_ref().filter(|d| d.stale())?;
692            let ids: Vec<&str> = d.requirements.iter().map(|q| q.id.as_str()).collect();
693            Some(format!("{} ({})", t.id, ids.join(", ")))
694        })
695        .collect();
696    if !stale.is_empty() {
697        let mut a = action(
698            "next",
699            "re-check against the spec",
700            "convergence assesses the code against the spec as it is now",
701            flag("speccoder run --converge".to_owned()),
702        );
703        a = a.with_run(Runnable::Converge);
704        out.push(item(
705            "stale",
706            None,
707            format!(
708                "the spec changed under {}",
709                count(stale.len(), "tick", "ticks")
710            ),
711            format!("proven against an earlier wording: {}", stale.join(", ")),
712            vec![a],
713        ));
714    }
715
716    // Most ticks regression-only: one diagnosis, never stored, never a stop.
717    if let Some(u) = unverified(r, path) {
718        let dominant = u
719            .counts
720            .first()
721            .map(|c| c.reason.clone())
722            .unwrap_or_default();
723        let mut actions = Vec::new();
724        let checks = action(
725            "next",
726            "show the plan checks",
727            "deterministic checks of the task list: where the plan leaves nothing to show",
728            flag("speccoder doctor".to_owned()),
729        );
730        match dominant.as_str() {
731            "no_scenarios" => {
732                if let Some(skill) = crate::format::at(path).planning().revises {
733                    let mut a = action(
734                        "plan",
735                        "add scenarios",
736                        "the format's clarify skill asks for the examples a test is written from",
737                        flag(format!("speccoder spec {skill}")),
738                    );
739                    a = a.with_run(Runnable::Skill {
740                        name: skill.to_owned(),
741                        arguments: String::new(),
742                    });
743                    actions.push(a);
744                } else {
745                    actions.push(checks.clone());
746                }
747            }
748            // The plan put the behaviour early or outside a story: the
749            // preset changes how future plans are written.
750            "already_passing" | "no_story"
751                if crate::format::at(path).kind() == speccoder_core::spec::FormatKind::Speckit
752                    && crate::preset::state(path).installed.is_none() =>
753            {
754                let mut a = action(
755                    "plan",
756                    "add the speccoder preset",
757                    "future task lists keep a story's behaviour inside its story; no spec file changes",
758                    "speccoder preset add --yes".to_owned(),
759                );
760                a = a.with_run(Runnable::PresetAdd { update: false });
761                // Refused anyway without Spec Kit: say why, never offer it bare.
762                if !crate::preset::speckit_version().is_some_and(|v| v >= crate::preset::NEEDS) {
763                    a.blocked =
764                        Some("needs Spec Kit's `specify` 1.2.0 or newer on PATH".to_owned());
765                }
766                a.confirm = Some(
767                    "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(),
768                );
769                actions.push(a);
770            }
771            _ => actions.push(checks.clone()),
772        }
773        let mut converge = action(
774            "next",
775            "converge",
776            "assess the code against the spec as it is now and append what remains",
777            flag("speccoder converge".to_owned()),
778        );
779        converge = converge.with_run(Runnable::Converge);
780        actions.push(converge);
781        actions.push(action(
782            "review",
783            "open the report",
784            "each task's evidence and why",
785            flag("speccoder report".to_owned()),
786        ));
787        let mut it = item(
788            "unverified",
789            None,
790            format!(
791                "Little was verified in `{}`: {} of {} ticks are regression-only",
792                r.feature, u.regression_only, u.ticks
793            ),
794            u.counts
795                .iter()
796                .map(|c| format!("{} · {}", c.text, c.tasks.join(" ")))
797                .collect::<Vec<_>>()
798                .join("; "),
799            actions,
800        );
801        it.unverified = Some(u);
802        out.push(it);
803    }
804
805    // What the last autopilot run refused instead of asking.
806    let denied = project_state
807        .as_deref()
808        .map(|s| crate::oracle::last_denials(s, feature))
809        .unwrap_or_default();
810    if !denied.is_empty() {
811        let read = action(
812            "work",
813            "see what autopilot refused",
814            "each call was refused for that run; approve one when you run again, or allow it in Settings",
815            flag("speccoder run --json".to_owned()),
816        );
817        out.push(item(
818            "autopilot_denied",
819            None,
820            count(
821                denied.len(),
822                "call autopilot refused instead of asking",
823                "calls autopilot refused instead of asking",
824            ),
825            denied
826                .iter()
827                .take(4)
828                .map(|d| format!("{}: {}", d.task, d.object))
829                .collect::<Vec<_>>()
830                .join("; "),
831            vec![read],
832        ));
833    }
834
835    // The spec, reviewed requirement by requirement.
836    if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
837        let t = rv.totals;
838        // The documents' open questions: answered before anything is built.
839        if !rv.questions.is_empty() {
840            let read = action(
841                "plan",
842                "answer the open questions",
843                "your words replace each question where it stands",
844                flag("speccoder review".to_owned()),
845            );
846            let mut actions = vec![read];
847            if let Some(skill) = &rv.revise_with {
848                let mut a = action(
849                    "plan",
850                    format!("clarify with {skill}"),
851                    "the format's skill asks you the questions and writes the answers in",
852                    flag(format!("speccoder spec {skill}")),
853                );
854                a = a
855                    .with_run(Runnable::Skill {
856                        name: skill.clone(),
857                        arguments: String::new(),
858                    })
859                    .with_role(crate::format::at(path).planning().role_of(skill));
860                actions.push(a);
861            }
862            out.push(item(
863                "question",
864                None,
865                count(
866                    rv.questions.len(),
867                    "open question in the spec",
868                    "open questions in the spec",
869                ),
870                rv.questions
871                    .iter()
872                    .take(3)
873                    .map(|q| format!("{}: {}", q.id, q.text))
874                    .collect::<Vec<_>>()
875                    .join("; "),
876                actions,
877            ));
878        }
879        // Two test authors read a requirement two ways.
880        let requirements = match crate::inspect(path, Some(feature)).data() {
881            Some(CommandData::Inspect(snap)) => crate::format::of(snap).oracle_requirements(snap),
882            _ => Vec::new(),
883        };
884        let ambiguous = match project_state
885            .as_deref()
886            .map(|s| crate::oracle::open_ambiguities(s, feature, &requirements))
887        {
888            Some(Ok(a)) => a,
889            None => Vec::new(),
890            // Unknown is never "nothing read two ways": a person looks.
891            Some(Err(t)) => {
892                out.push(item(
893                    "ambiguous",
894                    None,
895                    "a test draft cannot be read".to_owned(),
896                    format!("{} — Approve and Run waits until it is written again", t.0),
897                    vec![action(
898                        "review",
899                        "see the tests",
900                        "write the requirement's tests again",
901                        flag("speccoder review".to_owned()),
902                    )],
903                ));
904                Vec::new()
905            }
906        };
907        if let Some((req, scenario)) = ambiguous.first() {
908            let mut a = action(
909                "review",
910                "reading A is meant",
911                "the first author's tests stay",
912                flag(format!(
913                    "speccoder review tests {req} --reading A {scenario}"
914                )),
915            );
916            a = a.with_run(Runnable::OracleTests {
917                req: req.clone(),
918                op: speccoder_core::oracle::OracleOp::Reading,
919                path: None,
920                scenario: Some(scenario.clone()),
921                reading: Some(speccoder_core::oracle::Reading::A),
922            });
923            let mut b = action(
924                "review",
925                "reading B is meant",
926                "its tests are written again with that reading",
927                flag(format!(
928                    "speccoder review tests {req} --reading B {scenario}"
929                )),
930            );
931            b = b.with_run(Runnable::OracleTests {
932                req: req.clone(),
933                op: speccoder_core::oracle::OracleOp::Reading,
934                path: None,
935                scenario: Some(scenario.clone()),
936                reading: Some(speccoder_core::oracle::Reading::B),
937            });
938            out.push(item(
939                "ambiguous",
940                None,
941                count(
942                    ambiguous.len(),
943                    "scenario read two ways",
944                    "scenarios read two ways",
945                ),
946                ambiguous
947                    .iter()
948                    .map(|(r, s)| format!("{r}/{s}"))
949                    .collect::<Vec<_>>()
950                    .join(", ")
951                    + " — Approve and Run waits until you choose a reading or reword",
952                vec![a, b],
953            ));
954        }
955        if t.open + t.lapsed > 0 {
956            let pending: Vec<&str> = rv
957                .requirements
958                .iter()
959                .filter(|i| i.state == "open" || i.state == "lapsed")
960                .map(|i| i.id.as_str())
961                .collect();
962            let read = action(
963                "plan",
964                "read the requirements",
965                "each one, as the result will be judged against it",
966                flag("speccoder review".to_owned()),
967            );
968            let mut all = action(
969                "plan",
970                format!(
971                    "approve {}",
972                    count(pending.len(), "requirement", "requirements")
973                ),
974                "on their current wording, as you",
975                flag("speccoder review approve".to_owned()),
976            );
977            all = all.with_run(Runnable::Review {
978                ids: Vec::new(),
979                verdict: "approve".to_owned(),
980            });
981            out.push(item(
982                "review",
983                None,
984                if pending.len() == 1 {
985                    "1 requirement waits for your review".to_owned()
986                } else {
987                    format!("{} requirements wait for your review", pending.len())
988                },
989                if t.lapsed > 0 {
990                    format!(
991                        "{}; {} changed since you decided",
992                        pending.join(", "),
993                        t.lapsed
994                    )
995                } else {
996                    pending.join(", ")
997                },
998                vec![read, all],
999            ));
1000        }
1001        if t.changes_requested > 0 {
1002            let asked: Vec<String> = rv
1003                .requirements
1004                .iter()
1005                .filter(|i| i.state == "changes_requested")
1006                .map(|i| format!("{}: {}", i.id, i.comment.as_deref().unwrap_or_default()))
1007                .collect();
1008            let mut actions = Vec::new();
1009            if let Some(skill) = &rv.revise_with {
1010                let mut a = action(
1011                    "plan",
1012                    format!("revise the spec ({skill})"),
1013                    "the format's skill rewrites what you asked; you review the result",
1014                    flag(format!("speccoder spec {skill} \"…\"")),
1015                );
1016                a = a
1017                    .with_run(Runnable::Skill {
1018                        name: skill.clone(),
1019                        arguments: rv.revision.clone().unwrap_or_default(),
1020                    })
1021                    .with_role(crate::format::at(path).planning().role_of(skill));
1022                actions.push(a);
1023            }
1024            actions.push(action(
1025                "plan",
1026                "edit the spec yourself",
1027                "then approve the new wording",
1028                flag("speccoder review".to_owned()),
1029            ));
1030            out.push(item(
1031                "revise",
1032                None,
1033                format!(
1034                    "{} on the spec",
1035                    count(t.changes_requested, "change request", "change requests")
1036                ),
1037                asked.join("; "),
1038                actions,
1039            ));
1040        }
1041    }
1042
1043    if !r.uncommitted.is_empty() {
1044        let mut a = action(
1045            "next",
1046            format!("commit the planning files ({})", r.uncommitted.len()),
1047            "a run reads committed files only",
1048            format!(
1049                "git add {0} && git commit -m \"spec: …\" -- {0}",
1050                r.uncommitted.join(" ")
1051            ),
1052        );
1053        a = a.with_run(Runnable::Commit {
1054            paths: r.uncommitted.clone(),
1055        });
1056        out.push(item(
1057            "commit",
1058            None,
1059            "planning files not committed yet".to_owned(),
1060            format!("read them, then commit: {}", r.uncommitted.join(", ")),
1061            vec![a],
1062        ));
1063    }
1064
1065    if handoff(r) && stale.is_empty() {
1066        let mut pr = action(
1067            "review",
1068            "open a draft pull request",
1069            "push the branch and its evidence; open (or update) the draft",
1070            flag("speccoder pr --yes".to_owned()),
1071        );
1072        pr = pr.with_run(Runnable::PullRequest);
1073        pr.confirm = Some(
1074            "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(),
1075        );
1076        out.push(item(
1077            "handoff",
1078            None,
1079            format!("every task is done: review {} and hand it off", r.feature),
1080            format!(
1081                "{} on {} not merged",
1082                count(r.unmerged_commits, "commit", "commits"),
1083                r.branch.as_deref().unwrap_or_default()
1084            ),
1085            vec![pr],
1086        ));
1087    }
1088    if out.iter().any(|i| EVIDENCED.contains(&i.kind.as_str())) {
1089        let risk = risk_of(path, r);
1090        for i in out
1091            .iter_mut()
1092            .filter(|i| EVIDENCED.contains(&i.kind.as_str()))
1093        {
1094            i.risk.clone_from(&risk);
1095        }
1096    }
1097    out
1098}
1099
1100/// The offer to start a spec from `o`, the conversation that suggested it
1101/// preselected.
1102fn promote_item(
1103    plane: &Path,
1104    o: &crate::ladder::Offer,
1105    item: impl Fn(String, String, Vec<Action>) -> InboxItem,
1106) -> InboxItem {
1107    let at = format!("{}/{}", o.unit, o.task);
1108    let promote = action(
1109        "review",
1110        format!("start a spec from {}", o.task),
1111        "its requirement, word for word, becomes a spec of its own; the earlier tick is inherited, not verified",
1112        format!("speccoder spec promote {at}"),
1113    )
1114    .with_run(crate::ladder::promotion(o, crate::ladder::suggested_by(plane, o)));
1115    let not_now = action(
1116        "review",
1117        "not now",
1118        "offered again only when another signal holds",
1119        format!("speccoder spec promote {at} --decline"),
1120    )
1121    .with_run(Runnable::DeclinePromotion { task: at });
1122    item(
1123        format!(
1124            "{} grew past a {}",
1125            o.task,
1126            if o.bug { "bug fix" } else { "quick change" }
1127        ),
1128        o.signals
1129            .iter()
1130            .map(|s| s.text.clone())
1131            .collect::<Vec<_>>()
1132            .join(" · "),
1133        vec![promote, not_now],
1134    )
1135}
1136
1137/// The kinds about finished work, whose order follows its evidence.
1138const EVIDENCED: [&str; 3] = ["handoff", "unverified", "stale"];
1139
1140/// What a feature's ticks leave to the person, the riskiest first: their
1141/// states, drift and strength from the report, and the tick commits'
1142/// `Scope-Outside:` and `Executor:` trailers (one `git show` for all).
1143fn risk_of(path: &Path, r: &speccoder_core::StatusReport) -> Vec<speccoder_core::RiskReason> {
1144    use speccoder_core::inbox::Risk;
1145    let target = crate::oracle::ViewConfig::at(path)
1146        .strength_target
1147        .map(|t| f64::from(t) / 100.0);
1148    let mut n = [0usize; Risk::ALL.len()];
1149    let mut bump = |risk: Risk| n[Risk::ALL.iter().position(|x| *x == risk).unwrap_or(0)] += 1;
1150    for t in &r.tasks {
1151        match t.state.as_str() {
1152            "regression_only" => bump(Risk::RegressionOnly),
1153            "attested" => bump(Risk::Attested),
1154            _ => {}
1155        }
1156        if t.drift.is_some() {
1157            bump(Risk::Stale);
1158        }
1159        let strength = t
1160            .strength
1161            .as_deref()
1162            .and_then(|s| s.split_whitespace().next())
1163            .and_then(|v| v.parse::<f64>().ok());
1164        if t.state == "verified"
1165            && let (Some(s), Some(min)) = (strength, target)
1166            && s < min
1167        {
1168            bump(Risk::StrengthBelowTarget);
1169        }
1170    }
1171    let commits: Vec<&str> = r.tasks.iter().filter_map(|t| t.commit.as_deref()).collect();
1172    if !commits.is_empty() {
1173        // One call; a commit that is gone (rebased, squashed, shallow) is
1174        // left out, never the reason the others are not read.
1175        let mut args = vec![
1176            "log",
1177            "--no-walk=unsorted",
1178            "--ignore-missing",
1179            "--format=%(trailers:key=Scope-Outside,key=Executor,separator=%x01)%x1e",
1180        ];
1181        args.extend(commits.iter().copied());
1182        if let Ok(out) = crate::git::Git::at(path).run(&args) {
1183            for block in String::from_utf8_lossy(&out).split('\u{1e}') {
1184                if block.contains("Scope-Outside:") {
1185                    bump(Risk::ScopeOutside);
1186                }
1187                if block.contains("Executor:") {
1188                    bump(Risk::ExternalExecutor);
1189                }
1190            }
1191        }
1192    }
1193    Risk::ALL
1194        .iter()
1195        .zip(n)
1196        .filter(|(_, count)| *count > 0)
1197        .map(|(risk, count)| speccoder_core::RiskReason {
1198            reason: risk.as_str().to_owned(),
1199            count,
1200            text: risk.chip(count),
1201        })
1202        .collect()
1203}
1204
1205/// An item's place in *Ready to review*: its riskiest reason; then work
1206/// with no evidence to weigh (a spec to review, set-aside changes); strong
1207/// verified work last.
1208fn review_rank(i: &InboxItem) -> usize {
1209    use speccoder_core::inbox::Risk;
1210    match i.risk.first() {
1211        Some(r) => Risk::ALL
1212            .iter()
1213            .position(|x| x.as_str() == r.reason)
1214            .unwrap_or(0),
1215        None if EVIDENCED.contains(&i.kind.as_str()) => Risk::ALL.len() + 1,
1216        None => Risk::ALL.len(),
1217    }
1218}
1219
1220/// The feature's diagnosis when it has at least 3 speccoder ticks and more
1221/// than half are regression-only, and it has not been handed off (its
1222/// branch pushed, or merged with every task done).
1223fn unverified(r: &StatusReport, path: &Path) -> Option<speccoder_core::Unverified> {
1224    use speccoder_core::ReasonCount;
1225    use speccoder_core::regression::RegressionReason as R;
1226    let ticks = r.totals.verified + r.totals.regression_only;
1227    // A story's task that turned none of its scenarios, or closed a story
1228    // others turned, is progress the oracle shows — not missing evidence.
1229    let reason_of = |t: &&speccoder_core::TaskStatus| {
1230        t.regression
1231            .as_ref()
1232            .map_or(R::Unrecorded, |g| g.reason.clone())
1233    };
1234    let unproven: Vec<&speccoder_core::TaskStatus> = r
1235        .tasks
1236        .iter()
1237        .filter(|t| t.state == "regression_only")
1238        .filter(|t| {
1239            !matches!(
1240                reason_of(t),
1241                R::InProgress | R::StoryVerified | R::PlanningWork
1242            )
1243        })
1244        .collect();
1245    let regression = unproven.len();
1246    if ticks < 3 || regression * 2 <= ticks || handed_off(r, path) {
1247        return None;
1248    }
1249    let mut counts: Vec<ReasonCount> = Vec::new();
1250    for t in unproven {
1251        let reason = reason_of(&t);
1252        let name = reason.name().to_owned();
1253        let c = match counts.iter_mut().position(|c| c.reason == name) {
1254            Some(i) => &mut counts[i],
1255            None => {
1256                counts.push(ReasonCount {
1257                    reason: name,
1258                    n: 0,
1259                    tasks: Vec::new(),
1260                    text: String::new(),
1261                });
1262                let last = counts.len() - 1;
1263                &mut counts[last]
1264            }
1265        };
1266        c.n += 1;
1267        c.tasks.push(t.id.clone());
1268    }
1269    counts.sort_by(|a, b| b.n.cmp(&a.n).then(a.reason.cmp(&b.reason)));
1270    for c in &mut counts {
1271        c.text = reason_count_words(c);
1272    }
1273    Some(speccoder_core::Unverified {
1274        ticks,
1275        regression_only: regression,
1276        counts,
1277    })
1278}
1279
1280/// `7 had no user story`, `2 were already passing`.
1281fn reason_count_words(c: &speccoder_core::ReasonCount) -> String {
1282    let n = c.n;
1283    let were = if n == 1 { "was" } else { "were" };
1284    let what = match c.reason.as_str() {
1285        "no_story" => "had no user story".to_owned(),
1286        "no_scenarios" => "had a story with no acceptance scenario".to_owned(),
1287        "acceptance_off" => format!("{were} ticked with acceptance tests off"),
1288        "author_wrote_none" => "got no test from the test author".to_owned(),
1289        "author_failed" => "had acceptance tests that could not be used".to_owned(),
1290        "already_passing" => format!("{were} already passing"),
1291        "not_red" => "had acceptance tests that did not fail first".to_owned(),
1292        "tests_not_collected" => "had acceptance tests that never ran".to_owned(),
1293        "planning_work" => format!("{were} planning work (design documents)"),
1294        "story_verified" => {
1295            format!("{were} their story's checkpoint, its scenarios verified earlier")
1296        }
1297        "regression_unchecked" => {
1298            format!(
1299                "{were} ticked on checks that failed before and after, without per-test results"
1300            )
1301        }
1302        _ => format!("{were} ticked before reasons were recorded"),
1303    };
1304    format!("{n} {what}")
1305}
1306
1307/// The feature was handed off: its speccoder branch was pushed, or it is
1308/// merged with every task done.
1309fn handed_off(r: &StatusReport, path: &Path) -> bool {
1310    let merged = !r.tasks.is_empty()
1311        && r.totals.ready == 0
1312        && r.totals.blocked == 0
1313        && r.unmerged_commits == 0
1314        && r.branch.is_some();
1315    let pushed = r.branch.as_deref().is_some_and(|b| {
1316        crate::git::Git::at(path)
1317            .line(&[
1318                "for-each-ref",
1319                "--format=%(refname)",
1320                &format!("refs/remotes/*/{b}"),
1321            ])
1322            .is_ok_and(|l| !l.trim().is_empty())
1323    });
1324    merged || pushed
1325}
1326
1327/// Every task finished and the branch carries work not merged.
1328fn handoff(r: &StatusReport) -> bool {
1329    !r.tasks.is_empty()
1330        && r.totals.ready == 0
1331        && r.totals.blocked == 0
1332        && r.unmerged_commits > 0
1333        && r.branch.is_some()
1334}
1335
1336fn with_input(
1337    t: &speccoder_core::TaskStatus,
1338    verb: &str,
1339    why: &str,
1340    prompt: &str,
1341    run: Runnable,
1342    flag: &dyn Fn(String) -> String,
1343) -> Action {
1344    let option = if verb == "nudge" {
1345        "--note"
1346    } else {
1347        "--reason"
1348    };
1349    let mut a = action(
1350        "task",
1351        format!("{verb} {}", t.id),
1352        why,
1353        flag(format!("speccoder task {verb} {} {option} \"…\"", t.id)),
1354    );
1355    a = a.with_run(run);
1356    a.input = Some(prompt.to_owned());
1357    a
1358}
1359
1360/// The Needs You item of a bug that stopped for the person, if it did.
1361fn bug_item(
1362    b: &speccoder_core::bug::BugReport,
1363    flag: &dyn Fn(String) -> String,
1364) -> Option<InboxItem> {
1365    use speccoder_core::bug::Outcome;
1366    let key = b.key();
1367    let kind = match b.outcome {
1368        Outcome::AwaitingConfirmation => Kind::BugConfirm,
1369        Outcome::CannotReproduce => Kind::BugCannotReproduce,
1370        Outcome::UnchangedNotHolding => Kind::BugUnchangedNotHolding,
1371        // Kept as a regression test: decided.
1372        Outcome::AlreadySatisfied if b.commit.is_some() => return None,
1373        Outcome::AlreadySatisfied => Kind::BugAlreadySatisfied,
1374        _ if b.code.as_deref() == Some("BUG-REPRO-WEAK") => Kind::BugReproWeak,
1375        _ => return None,
1376    };
1377    let task = || b.task.clone();
1378    let mut actions = Vec::new();
1379    match kind {
1380        Kind::BugConfirm => {
1381            let digest = b.confirm_digest.clone().unwrap_or_default();
1382            let mut yes = action(
1383                "next",
1384                format!("confirm {}'s reproductions", b.task),
1385                "they show the bug: they are frozen and the fix runs",
1386                format!("speccoder bug confirm {key} --digest {digest}"),
1387            );
1388            yes = yes.with_run(Runnable::BugConfirm {
1389                task: key.clone(),
1390                digest: digest.clone(),
1391                reject: false,
1392            });
1393            let mut no = action(
1394                "next",
1395                format!("reject {}'s reproductions", b.task),
1396                "the author tries again, told why",
1397                format!("speccoder bug confirm {key} --reject --reason \"…\""),
1398            );
1399            no = no.with_run(Runnable::BugConfirm {
1400                task: key.clone(),
1401                digest,
1402                reject: true,
1403            });
1404            no.input = Some("Why they do not show the bug".to_owned());
1405            actions.extend([yes, no]);
1406        }
1407        Kind::BugAlreadySatisfied => {
1408            let mut keep = action(
1409                "next",
1410                format!("keep {}'s reproduction as a regression test", b.task),
1411                "the reproduction is committed and the task accepted on your word: attested, not verified",
1412                format!("speccoder bug keep {key} --reason \"kept as a regression test\""),
1413            );
1414            keep = keep.with_run(Runnable::BugKeep { task: key.clone() });
1415            keep.input = Some("Why it is kept".to_owned());
1416            let mut drop = action(
1417                "task",
1418                format!("drop {}", b.task),
1419                "it is fixed already",
1420                flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1421            );
1422            drop = drop.with_run(Runnable::Skip { task: task() });
1423            drop.input = Some("Why it is dropped".to_owned());
1424            actions.extend([keep, drop]);
1425        }
1426        Kind::BugReproWeak => {
1427            let mut rule = action(
1428                "task",
1429                format!("rule on {}", b.task),
1430                "the tests are right: the special case is not a fix",
1431                flag(format!(
1432                    "speccoder task rule {} --tests-right --reason \"…\"",
1433                    b.task
1434                )),
1435            );
1436            rule = rule.with_run(Runnable::Rule { task: task() });
1437            rule.input = Some("Why the tests are right".to_owned());
1438            actions.push(rule);
1439            actions.push(action(
1440                "task",
1441                "bring your own test",
1442                "one per Expected statement",
1443                format!(
1444                    "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1445                    b.triad.current.text
1446                ),
1447            ));
1448        }
1449        _ => {
1450            let mut again = action(
1451                "next",
1452                format!("reproduce {} again", b.task),
1453                "a fresh set of tries",
1454                format!("speccoder bug run --task {key}"),
1455            );
1456            again = again.with_run(Runnable::BugRun {
1457                slug: None,
1458                task: Some(key.clone()),
1459            });
1460            actions.push(again);
1461            actions.push(action(
1462                "task",
1463                "bring your own test",
1464                "one per Expected statement",
1465                format!(
1466                    "speccoder bug \"{}\" --expected \"…\" --test <file>::<test>",
1467                    b.triad.current.text
1468                ),
1469            ));
1470            if !b.home.kind.native() {
1471                actions.push(action(
1472                    "task",
1473                    format!("reword {}", b.requirement),
1474                    "the triad in other words (what was confirmed lapses)",
1475                    format!(
1476                        "speccoder review edit {} \"…\" --feature {}",
1477                        b.requirement, b.unit
1478                    ),
1479                ));
1480                let mut drop = action(
1481                    "task",
1482                    format!("drop {}", b.task),
1483                    "nothing is fixed",
1484                    flag(format!("speccoder task skip {} --reason \"…\"", b.task)),
1485                );
1486                drop = drop.with_run(Runnable::Skip { task: task() });
1487                drop.input = Some("Why it is dropped".to_owned());
1488                actions.push(drop);
1489            }
1490        }
1491    }
1492    let detail = b
1493        .handoff
1494        .as_ref()
1495        .map(|h| {
1496            format!(
1497                "{} tries; reproduced: {}; not reproduced: {}; already passing: {}",
1498                h.tries.len(),
1499                if h.statements.reproduced.is_empty() {
1500                    "none".to_owned()
1501                } else {
1502                    h.statements.reproduced.join(", ")
1503                },
1504                if h.statements.not_reproduced.is_empty() {
1505                    "none".to_owned()
1506                } else {
1507                    h.statements.not_reproduced.join(", ")
1508                },
1509                if h.statements.satisfied.is_empty() {
1510                    "none".to_owned()
1511                } else {
1512                    h.statements.satisfied.join(", ")
1513                },
1514            )
1515        })
1516        .unwrap_or_else(|| format!("{} — {}", b.title, b.outcome.as_str().replace('_', " ")));
1517    Some(InboxItem {
1518        unverified: None,
1519        risk: Vec::new(),
1520        tool: None,
1521        kind: kind.as_str().to_owned(),
1522        group: kind.group().as_str().to_owned(),
1523        feature: b.unit.clone(),
1524        task: Some(b.task.clone()),
1525        title: match kind {
1526            Kind::BugConfirm => format!(
1527                "{}: {} reproduction(s) wait for your confirmation",
1528                b.task,
1529                b.reproductions.iter().filter(|r| r.reproduces()).count()
1530            ),
1531            Kind::BugAlreadySatisfied => format!(
1532                "{}: already fixed — keep the reproduction or drop it",
1533                b.task
1534            ),
1535            Kind::BugReproWeak => {
1536                format!("{}: a special-case fix passes the reproductions", b.task)
1537            }
1538            Kind::BugUnchangedNotHolding => {
1539                format!("{}: an Unchanged statement does not hold today", b.task)
1540            }
1541            _ => format!("{}: could not be reproduced", b.task),
1542        },
1543        detail,
1544        since: b.confirmation.as_ref().map(|c| c.at),
1545        actions,
1546    })
1547}
1548
1549/// Why a new attempt cannot start now: planning files a run cannot read
1550/// yet, or a waiting call's attempt that holds the worktree.
1551fn retry_blocked(r: &StatusReport, waiting: &[&str]) -> Option<String> {
1552    if !r.uncommitted.is_empty() {
1553        Some("commit the planning files first".to_owned())
1554    } else {
1555        waiting
1556            .first()
1557            .map(|w| format!("{w} waits for your decision and holds the worktree: decide it first"))
1558    }
1559}
1560
1561/// The open tasks that wait on `id`, directly or through another.
1562fn dependents(tasks: &[speccoder_core::TaskStatus], id: &str) -> Vec<String> {
1563    let mut waiting: Vec<&str> = vec![id];
1564    loop {
1565        let more: Vec<&str> = tasks
1566            .iter()
1567            .filter(|t| !waiting.contains(&t.id.as_str()))
1568            .filter(|t| t.blocked_by.iter().any(|b| waiting.contains(&b.as_str())))
1569            .map(|t| t.id.as_str())
1570            .collect();
1571        if more.is_empty() {
1572            break;
1573        }
1574        waiting.extend(more);
1575    }
1576    tasks
1577        .iter()
1578        .filter(|t| t.id != id && waiting.contains(&t.id.as_str()))
1579        .map(|t| t.id.clone())
1580        .collect()
1581}
1582
1583/// I did it, Hint & retry, Skip: the three ways on from a task that needs
1584/// the person.
1585fn yours_actions(
1586    t: &speccoder_core::TaskStatus,
1587    waiting: &[String],
1588    retry: Option<String>,
1589    flag: &dyn Fn(String) -> String,
1590) -> Vec<Action> {
1591    let mut done = action(
1592        "task",
1593        format!("I did it: {}", t.id),
1594        "tick it on your word (attested, never shown as verified)",
1595        flag(format!("speccoder task done {}", t.id)),
1596    );
1597    done = done.with_run(Runnable::Done { task: t.id.clone() });
1598    let mut hint = action(
1599        "task",
1600        format!("hint & retry {}", t.id),
1601        "a note the agent reads, then a new attempt now",
1602        flag(format!("speccoder task hint {} \"…\"", t.id)),
1603    );
1604    hint = hint.with_run(Runnable::Hint { task: t.id.clone() });
1605    hint.input = Some("The hint".to_owned());
1606    hint.blocked = retry;
1607    let mut skip = skip(t, flag);
1608    if !waiting.is_empty() {
1609        let stay = if waiting.len() == 1 { "stays" } else { "stay" };
1610        skip.why = format!("runs pass it by; {} {stay} blocked", waiting.join(", "));
1611    }
1612    vec![done, hint, skip]
1613}
1614
1615fn nudge(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1616    with_input(
1617        t,
1618        "nudge",
1619        "a note its next attempts read",
1620        "The note",
1621        Runnable::Nudge { task: t.id.clone() },
1622        flag,
1623    )
1624}
1625
1626fn skip(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1627    with_input(
1628        t,
1629        "skip",
1630        "runs pass it by; what depends on it stays blocked",
1631        "Why it is skipped",
1632        Runnable::Skip { task: t.id.clone() },
1633        flag,
1634    )
1635}
1636
1637/// The Needs You item of a tick a gate blocked — *T004 blocked by licence*,
1638/// or *licence waits for your trust* when that is why: kind, title, detail
1639/// and actions.
1640fn gate_item(
1641    root: &Path,
1642    state: Option<&Path>,
1643    t: &speccoder_core::TaskStatus,
1644    a: &speccoder_core::LastAttempt,
1645    flag: &dyn Fn(String) -> String,
1646) -> (&'static str, String, String, Vec<Action>) {
1647    let gate = a
1648        .reason
1649        .strip_prefix("Blocked by ")
1650        .map(|r| r.split(" — ").next().unwrap_or(r))
1651        .unwrap_or_default()
1652        .to_owned();
1653    let mut again = action(
1654        "work",
1655        format!("run {} again", t.id),
1656        "a new attempt; its tick waits for the gate again",
1657        flag(format!("speccoder run --task {}", t.id)),
1658    );
1659    again = again.with_run(Runnable::Run {
1660        task: Some(t.id.clone()),
1661        agent: None,
1662        cap: None,
1663    });
1664    let untrusted = a.reason.contains(crate::hooks::gate::AWAITS_TRUST)
1665        || a.reason.contains(crate::hooks::gate::CHANGED);
1666    if untrusted {
1667        let digest = crate::hooks::load(root)
1668            .ok()
1669            .and_then(|h| h.into_iter().find(|d| d.name == gate))
1670            .map(|d| crate::hooks::digest(&d, root).digest)
1671            .unwrap_or_default();
1672        let mut trust = action(
1673            "hooks",
1674            format!("trust {gate}…"),
1675            "see exactly what it runs, then let it stand before every tick",
1676            format!("speccoder hooks trust {gate}"),
1677        )
1678        .with_run(Runnable::HookTrust {
1679            name: gate.clone(),
1680            digest,
1681        });
1682        trust.confirm = Some(format!(
1683            "It stands before every tick from now on; only exit 0 lets {} through.",
1684            t.id
1685        ));
1686        return (
1687            "hook_untrusted",
1688            format!("{gate} waits for your trust"),
1689            format!("It blocks {}'s tick until you trust it.", t.id),
1690            vec![trust, again],
1691        );
1692    }
1693    let test = action(
1694        "hooks",
1695        format!("test {gate}"),
1696        "run the gate alone on the checkout",
1697        format!("speccoder hooks test {gate}"),
1698    )
1699    .with_run(Runnable::HookTest { name: gate.clone() });
1700    (
1701        "gate_blocked",
1702        format!("{} blocked by {gate}", t.id),
1703        // The gate's words, from its last block in speccoder's state.
1704        state
1705            .and_then(|s| {
1706                crate::hooks::runs(&crate::hooks::dir(s, root))
1707                    .remove(&gate)?
1708                    .into_iter()
1709                    .rev()
1710                    .find(|r| r.outcome == "blocked")?
1711                    .error
1712            })
1713            .unwrap_or_else(|| {
1714                format!("{gate} did not let the tick through: speccoder hooks test {gate}")
1715            }),
1716        vec![again, test, skip(t, flag)],
1717    )
1718}
1719
1720fn accept(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
1721    with_input(
1722        t,
1723        "accept",
1724        "tick it on your word (attested, never shown as verified)",
1725        "Why it is done",
1726        Runnable::Accept { task: t.id.clone() },
1727        flag,
1728    )
1729}
1730
1731/// The same task with the next agent that can start here: one attempt,
1732/// judged as every attempt is. Offered after an external agent's attempt
1733/// (its run is `external_<agent>_…`), never after a native one.
1734fn try_with(
1735    task: &str,
1736    last: &speccoder_core::LastAttempt,
1737    flag: &dyn Fn(String) -> String,
1738    on_path: impl Fn(&str) -> bool,
1739) -> Option<Action> {
1740    let current = last
1741        .run_id
1742        .as_deref()
1743        .and_then(|r| r.strip_prefix("external_"))
1744        .and_then(|r| r.split('_').next())
1745        .filter(|a| *a != "command")?;
1746    let next = crate::acp::ready::next_after(Some(current), &on_path)?;
1747    Some(
1748        action(
1749            "work",
1750            format!("try {task} with {}", next.name),
1751            "the same task, another agent: one attempt, judged the same way",
1752            flag(format!("speccoder run --task {task} --agent {}", next.id)),
1753        )
1754        .with_run(Runnable::Run {
1755            task: Some(task.to_owned()),
1756            agent: Some(next.id.to_owned()),
1757            cap: None,
1758        }),
1759    )
1760}
1761
1762#[cfg(test)]
1763mod try_with_tests {
1764    use super::*;
1765
1766    #[test]
1767    fn a_failed_agent_attempt_offers_the_next_agent_and_a_native_one_does_not() {
1768        let t = "T005";
1769        let last = |run: &str| speccoder_core::LastAttempt {
1770            outcome: "verification_failed".to_owned(),
1771            reason: String::new(),
1772            run_id: Some(run.to_owned()),
1773            reference: String::new(),
1774            story_open: Vec::new(),
1775            own_test: None,
1776        };
1777        let flag = |c: String| c;
1778        let both = |x: &str| x == "copilot" || x == "opencode";
1779        let a = try_with(t, &last("external_copilot_1"), &flag, both)
1780            .unwrap_or_else(|| panic!("offered"));
1781        assert_eq!(a.command, "speccoder run --task T005 --agent opencode");
1782        assert_eq!(
1783            a.run,
1784            Some(Runnable::Run {
1785                task: Some("T005".to_owned()),
1786                agent: Some("opencode".to_owned()),
1787                cap: None
1788            })
1789        );
1790        assert!(try_with(t, &last("run_01ABC"), &flag, both).is_none());
1791        assert!(try_with(t, &last("external_command_1"), &flag, both).is_none());
1792        let alone = |x: &str| x == "copilot";
1793        assert!(try_with(t, &last("external_copilot_1"), &flag, alone).is_none());
1794    }
1795}
1796
1797#[cfg(test)]
1798mod order_tests {
1799    use super::*;
1800
1801    fn at(kind: &str, feature: &str, since: Option<u64>, risk: &[(&str, usize)]) -> InboxItem {
1802        InboxItem {
1803            kind: kind.to_owned(),
1804            group: Kind::named(kind)
1805                .map_or("blocked_on_you", |k| k.group().as_str())
1806                .to_owned(),
1807            feature: feature.to_owned(),
1808            task: None,
1809            title: String::new(),
1810            detail: String::new(),
1811            since,
1812            actions: Vec::new(),
1813            unverified: None,
1814            tool: None,
1815            risk: risk
1816                .iter()
1817                .map(|(r, n)| speccoder_core::RiskReason {
1818                    reason: (*r).to_owned(),
1819                    count: *n,
1820                    text: String::new(),
1821                })
1822                .collect(),
1823        }
1824    }
1825
1826    #[test]
1827    fn blocked_comes_first_oldest_first_and_review_riskiest_first() {
1828        let mut items = vec![
1829            at("handoff", "strong", None, &[]),
1830            at("review", "spec", None, &[]),
1831            at("handoff", "attested", None, &[("attested", 1)]),
1832            at("question", "q", Some(20), &[]),
1833            at(
1834                "unverified",
1835                "regression",
1836                None,
1837                &[("regression_only", 3), ("attested", 1)],
1838            ),
1839            at("approval", "a", Some(30), &[]),
1840            at("stale", "stale", None, &[("stale", 2)]),
1841            at("unknown", "unreadable", None, &[]),
1842            at("stuck", "s", Some(10), &[]),
1843        ];
1844        sort(&mut items);
1845        let order: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
1846        assert_eq!(
1847            order,
1848            [
1849                // Blocked on you: the unreadable journal, then the oldest.
1850                "unreadable",
1851                "s",
1852                "q",
1853                "a",
1854                // Ready to review: the riskiest reason first, strong verified last.
1855                "regression",
1856                "attested",
1857                "stale",
1858                "spec",
1859                "strong",
1860            ]
1861        );
1862    }
1863}