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