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.
20pub const KINDS: [&str; 10] = [
21    "approval", "ruling", "gaming", "stale", "question", "review", "revise", "stuck", "commit",
22    "handoff",
23];
24
25/// `speccoder inbox [--feature]`: the decisions waiting in every feature of
26/// the project at `path`, or in `feature` only.
27#[must_use]
28pub fn inbox(path: &Path, feature: Option<&str>) -> CommandResult {
29    inbox_with(path, feature, crate::worktree::state_dir().as_deref())
30}
31
32/// [`inbox`] with an explicit state directory (where waiting calls and
33/// held attempts are kept).
34#[must_use]
35pub fn inbox_with(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
36    let listed = crate::current::list(path, None);
37    let Some(CommandData::Features(f)) = listed.data() else {
38        return CommandResult::carried("inbox", &listed);
39    };
40    // `--feature` takes a name or a number, as everywhere: the one it selects.
41    let only = match feature {
42        Some(_) => match crate::current::list(path, feature).data() {
43            Some(CommandData::Features(one)) => one.current.clone(),
44            _ => None,
45        },
46        None => None,
47    };
48    if feature.is_some() && only.is_none() {
49        return CommandResult::carried("inbox", &crate::status::report(path, feature));
50    }
51    let names: Vec<String> = f
52        .features
53        .iter()
54        .map(|e| e.name.clone())
55        .filter(|n| only.as_ref().is_none_or(|o| o == n))
56        .collect();
57    let several = names.len() > 1;
58    let mut items = Vec::new();
59    for name in &names {
60        items.extend(of_feature(path, name, state, several));
61    }
62    let rank = |k: &str| KINDS.iter().position(|x| *x == k).unwrap_or(KINDS.len());
63    items.sort_by(|a, b| {
64        rank(&a.kind)
65            .cmp(&rank(&b.kind))
66            .then(
67                a.since
68                    .unwrap_or(u64::MAX)
69                    .cmp(&b.since.unwrap_or(u64::MAX)),
70            )
71            .then(a.feature.cmp(&b.feature))
72            .then(a.task.cmp(&b.task))
73    });
74    let message = summary(&items, names.len());
75    CommandResult::new("inbox", CommandStatus::Ok, &message).with_data(CommandData::Inbox(
76        Box::new(InboxReport {
77            items,
78            features: names.len(),
79        }),
80    ))
81}
82
83/// `n` with the word for one or for many: `1 tick`, `2 ticks`.
84fn count(n: usize, one: &str, many: &str) -> String {
85    format!("{n} {}", if n == 1 { one } else { many })
86}
87
88/// `3 decisions waiting in 2 features: 1 approval, 2 stuck`.
89fn summary(items: &[InboxItem], features: usize) -> String {
90    if items.is_empty() {
91        return format!(
92            "nothing waits for you in {}: the agents can work",
93            count(features, "feature", "features")
94        );
95    }
96    let mut in_features: Vec<&str> = items.iter().map(|i| i.feature.as_str()).collect();
97    in_features.sort_unstable();
98    in_features.dedup();
99    let parts: Vec<String> = KINDS
100        .iter()
101        .filter_map(|k| {
102            let n = items.iter().filter(|i| i.kind == *k).count();
103            (n > 0).then(|| format!("{n} {k}"))
104        })
105        .collect();
106    format!(
107        "{} waiting in {}: {}",
108        count(items.len(), "decision", "decisions"),
109        count(in_features.len(), "feature", "features"),
110        parts.join(", ")
111    )
112}
113
114/// One feature's items. `several`: commands name the feature.
115fn of_feature(path: &Path, feature: &str, state: Option<&Path>, several: bool) -> Vec<InboxItem> {
116    let report = crate::status::report(path, Some(feature));
117    let Some(CommandData::Report(r)) = report.data() else {
118        return Vec::new();
119    };
120    let approvals: Option<ApprovalsReport> =
121        state.and_then(|s| {
122            match crate::approvals::decide_with(
123                path,
124                Some(feature),
125                crate::approvals::Verb::List,
126                s,
127            )
128            .data()
129            {
130                Some(CommandData::Approvals(a)) => Some((**a).clone()),
131                _ => None,
132            }
133        });
134    let flag = |command: String| {
135        if several {
136            format!("{command} --feature {feature}")
137        } else {
138            command
139        }
140    };
141    let item = |kind: &str, task: Option<&str>, title: String, detail: String, actions| InboxItem {
142        kind: kind.to_owned(),
143        feature: feature.to_owned(),
144        task: task.map(str::to_owned),
145        title,
146        detail,
147        since: None,
148        actions,
149    };
150    let mut out = Vec::new();
151    let waiting: Vec<&str> = approvals
152        .iter()
153        .flat_map(|a| &a.pending)
154        .map(|p| p.task.as_str())
155        .collect();
156
157    // A run waits on these.
158    for p in approvals.iter().flat_map(|a| &a.pending) {
159        let question = p.kind == "question";
160        let mut yes = action(
161            "next",
162            if question {
163                format!("answer {}", p.task)
164            } else {
165                format!("approve {}", p.task)
166            },
167            if question {
168                "the attempt resumes with your answer".to_owned()
169            } else {
170                format!("allow {} for the task", p.covers)
171            },
172            flag(format!("speccoder task approve {}", p.task)),
173        );
174        yes.run = Some(Runnable::Approve {
175            task: p.task.clone(),
176        });
177        if question {
178            yes.input = Some("Your answer".to_owned());
179        }
180        let mut no = action(
181            "next",
182            format!("reject {}", p.task),
183            "the model is told your reason",
184            flag(format!("speccoder task reject {} --reason \"…\"", p.task)),
185        );
186        no.run = Some(Runnable::Reject {
187            task: p.task.clone(),
188        });
189        no.input = Some("Why (the model is told)".to_owned());
190        let mut i = item(
191            "approval",
192            Some(&p.task),
193            if question {
194                format!("{} asks you", p.task)
195            } else {
196                format!("{} waits for your approval", p.task)
197            },
198            p.question
199                .clone()
200                .filter(|_| question)
201                .unwrap_or_else(|| format!("{} — {}", p.covers, p.reason)),
202            vec![yes, no],
203        );
204        i.since = p.waiting_since;
205        out.push(i);
206    }
207
208    let root = Path::new(path);
209    let project_state = state.and_then(|s| {
210        crate::format::at(root)
211            .detect(root)
212            .map(|project| crate::worktree::project_state_dir(s, &project))
213    });
214    for t in &r.tasks {
215        let done = matches!(
216            t.state.as_str(),
217            "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
218        );
219        if done || waiting.contains(&t.id.as_str()) {
220            continue;
221        }
222        let held = project_state
223            .as_deref()
224            .and_then(|s| crate::strength::held(&crate::strength::dir(s, feature, &t.id)));
225        if let Some(held) = held {
226            let mut rule = action(
227                "task",
228                format!("rule on {}", t.id),
229                "the tests are right: the flagged alternatives are not what the spec allows",
230                flag(format!(
231                    "speccoder task rule {} --tests-right --reason \"…\"",
232                    t.id
233                )),
234            );
235            rule.run = Some(Runnable::Rule { task: t.id.clone() });
236            rule.input = Some("Why the tests are right".to_owned());
237            let findings: Vec<String> = held
238                .findings
239                .iter()
240                .map(|p| format!("{} ({})", p.scenario, p.rationale))
241                .collect();
242            out.push(item(
243                "ruling",
244                Some(&t.id),
245                format!("{}'s tests reject an alternative the spec may allow", t.id),
246                findings.join("; "),
247                vec![rule, nudge(t, &flag), skip(t, &flag)],
248            ));
249            continue;
250        }
251        let Some(a) = &t.last_attempt else {
252            continue;
253        };
254        if a.outcome == "suspected_gaming" {
255            out.push(item(
256                "gaming",
257                Some(&t.id),
258                format!("{}: the monitor suspects a shortcut", t.id),
259                a.reason.clone(),
260                vec![nudge(t, &flag), skip(t, &flag), accept(t, &flag)],
261            ));
262            continue;
263        }
264        let mut actions = Vec::new();
265        if t.state == "ready" {
266            let mut again = action(
267                "work",
268                format!("run {} again", t.id),
269                "with what the last attempt left: its facts and your notes",
270                flag(format!("speccoder run --task {}", t.id)),
271            );
272            again.run = Some(Runnable::Run {
273                task: Some(t.id.clone()),
274            });
275            again.blocked = if !r.uncommitted.is_empty() {
276                Some("commit the planning files first".to_owned())
277            } else {
278                // A waiting call's attempt holds the worktree.
279                waiting.first().map(|w| {
280                    format!("{w} waits for your decision and holds the worktree: decide it first")
281                })
282            };
283            actions.push(again);
284        }
285        actions.extend([nudge(t, &flag), skip(t, &flag), accept(t, &flag)]);
286        out.push(item(
287            "stuck",
288            Some(&t.id),
289            format!("{}'s last attempt: {}", t.id, a.outcome.replace('_', " ")),
290            a.reason.clone(),
291            actions,
292        ));
293    }
294
295    let stale: Vec<String> = r
296        .tasks
297        .iter()
298        .filter_map(|t| {
299            let d = t.drift.as_ref().filter(|d| d.stale())?;
300            let ids: Vec<&str> = d.requirements.iter().map(|q| q.id.as_str()).collect();
301            Some(format!("{} ({})", t.id, ids.join(", ")))
302        })
303        .collect();
304    if !stale.is_empty() {
305        let mut a = action(
306            "next",
307            "re-check against the spec",
308            "convergence assesses the code against the spec as it is now",
309            flag("speccoder run --converge".to_owned()),
310        );
311        a.run = Some(Runnable::Converge);
312        out.push(item(
313            "stale",
314            None,
315            format!(
316                "the spec changed under {}",
317                count(stale.len(), "tick", "ticks")
318            ),
319            format!("proven against an earlier wording: {}", stale.join(", ")),
320            vec![a],
321        ));
322    }
323
324    // The spec, reviewed requirement by requirement.
325    if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
326        let t = rv.totals;
327        // The documents' open questions: answered before anything is built.
328        if !rv.questions.is_empty() {
329            let read = action(
330                "plan",
331                "answer the open questions",
332                "your words replace each question where it stands",
333                flag("speccoder review".to_owned()),
334            );
335            let mut actions = vec![read];
336            if let Some(skill) = &rv.revise_with {
337                let mut a = action(
338                    "plan",
339                    format!("clarify with {skill}"),
340                    "the format's skill asks you the questions and writes the answers in",
341                    flag(format!("speccoder spec {skill}")),
342                );
343                a.run = Some(Runnable::Skill {
344                    name: skill.clone(),
345                    arguments: String::new(),
346                });
347                actions.push(a);
348            }
349            out.push(item(
350                "question",
351                None,
352                count(
353                    rv.questions.len(),
354                    "open question in the spec",
355                    "open questions in the spec",
356                ),
357                rv.questions
358                    .iter()
359                    .take(3)
360                    .map(|q| format!("{}: {}", q.id, q.text))
361                    .collect::<Vec<_>>()
362                    .join("; "),
363                actions,
364            ));
365        }
366        if t.open + t.lapsed > 0 {
367            let pending: Vec<&str> = rv
368                .requirements
369                .iter()
370                .filter(|i| i.state == "open" || i.state == "lapsed")
371                .map(|i| i.id.as_str())
372                .collect();
373            let read = action(
374                "plan",
375                "read the requirements",
376                "each one, as the result will be judged against it",
377                flag("speccoder review".to_owned()),
378            );
379            let mut all = action(
380                "plan",
381                format!(
382                    "approve {}",
383                    count(pending.len(), "requirement", "requirements")
384                ),
385                "on their current wording, as you",
386                flag("speccoder review approve".to_owned()),
387            );
388            all.run = Some(Runnable::Review {
389                ids: Vec::new(),
390                verdict: "approve".to_owned(),
391            });
392            out.push(item(
393                "review",
394                None,
395                if pending.len() == 1 {
396                    "1 requirement waits for your review".to_owned()
397                } else {
398                    format!("{} requirements wait for your review", pending.len())
399                },
400                if t.lapsed > 0 {
401                    format!(
402                        "{}; {} changed since you decided",
403                        pending.join(", "),
404                        t.lapsed
405                    )
406                } else {
407                    pending.join(", ")
408                },
409                vec![read, all],
410            ));
411        }
412        if t.changes_requested > 0 {
413            let asked: Vec<String> = rv
414                .requirements
415                .iter()
416                .filter(|i| i.state == "changes_requested")
417                .map(|i| format!("{}: {}", i.id, i.comment.as_deref().unwrap_or_default()))
418                .collect();
419            let mut actions = Vec::new();
420            if let Some(skill) = &rv.revise_with {
421                let mut a = action(
422                    "plan",
423                    format!("revise the spec ({skill})"),
424                    "the format's skill rewrites what you asked; you review the result",
425                    flag(format!("speccoder spec {skill} \"…\"")),
426                );
427                a.run = Some(Runnable::Skill {
428                    name: skill.clone(),
429                    arguments: rv.revision.clone().unwrap_or_default(),
430                });
431                actions.push(a);
432            }
433            actions.push(action(
434                "plan",
435                "edit the spec yourself",
436                "then approve the new wording",
437                flag("speccoder review".to_owned()),
438            ));
439            out.push(item(
440                "revise",
441                None,
442                format!(
443                    "{} on the spec",
444                    count(t.changes_requested, "change request", "change requests")
445                ),
446                asked.join("; "),
447                actions,
448            ));
449        }
450    }
451
452    if !r.uncommitted.is_empty() {
453        let mut a = action(
454            "next",
455            format!("commit the planning files ({})", r.uncommitted.len()),
456            "a run reads committed files only",
457            format!(
458                "git add {0} && git commit -m \"spec: …\" -- {0}",
459                r.uncommitted.join(" ")
460            ),
461        );
462        a.run = Some(Runnable::Commit {
463            paths: r.uncommitted.clone(),
464        });
465        out.push(item(
466            "commit",
467            None,
468            "planning files not committed yet".to_owned(),
469            format!("read them, then commit: {}", r.uncommitted.join(", ")),
470            vec![a],
471        ));
472    }
473
474    if handoff(r) && stale.is_empty() {
475        let mut pr = action(
476            "review",
477            "open a draft pull request",
478            "push the branch and its evidence; open (or update) the draft",
479            flag("speccoder pr --yes".to_owned()),
480        );
481        pr.run = Some(Runnable::PullRequest);
482        pr.confirm = Some(
483            "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(),
484        );
485        out.push(item(
486            "handoff",
487            None,
488            format!("every task is done: review {} and hand it off", r.feature),
489            format!(
490                "{} on {} not merged",
491                count(r.unmerged_commits, "commit", "commits"),
492                r.branch.as_deref().unwrap_or_default()
493            ),
494            vec![pr],
495        ));
496    }
497    out
498}
499
500/// Every task finished and the branch carries work not merged.
501fn handoff(r: &StatusReport) -> bool {
502    !r.tasks.is_empty()
503        && r.totals.ready == 0
504        && r.totals.blocked == 0
505        && r.unmerged_commits > 0
506        && r.branch.is_some()
507}
508
509fn with_input(
510    t: &speccoder_core::TaskStatus,
511    verb: &str,
512    why: &str,
513    prompt: &str,
514    run: Runnable,
515    flag: &dyn Fn(String) -> String,
516) -> Action {
517    let option = if verb == "nudge" {
518        "--note"
519    } else {
520        "--reason"
521    };
522    let mut a = action(
523        "task",
524        format!("{verb} {}", t.id),
525        why,
526        flag(format!("speccoder task {verb} {} {option} \"…\"", t.id)),
527    );
528    a.run = Some(run);
529    a.input = Some(prompt.to_owned());
530    a
531}
532
533fn nudge(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
534    with_input(
535        t,
536        "nudge",
537        "a note its next attempts read",
538        "The note",
539        Runnable::Nudge { task: t.id.clone() },
540        flag,
541    )
542}
543
544fn skip(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
545    with_input(
546        t,
547        "skip",
548        "runs pass it by; what depends on it stays blocked",
549        "Why it is skipped",
550        Runnable::Skip { task: t.id.clone() },
551        flag,
552    )
553}
554
555fn accept(t: &speccoder_core::TaskStatus, flag: &dyn Fn(String) -> String) -> Action {
556    with_input(
557        t,
558        "accept",
559        "tick it on your word (attested, never shown as verified)",
560        "Why it is done",
561        Runnable::Accept { task: t.id.clone() },
562        flag,
563    )
564}