Skip to main content

speccoder_runtime/
inbox.rs

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