1use 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
18pub 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#[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#[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 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 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 .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
117fn count(n: usize, one: &str, many: &str) -> String {
119 format!("{n} {}", if n == 1 { one } else { many })
120}
121
122fn 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
168fn 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
194fn 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 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 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 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 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 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 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 if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
586 let t = rv.totals;
587 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 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 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
840fn 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
873fn 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 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}