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