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; 10] = [
21 "approval", "ruling", "gaming", "stale", "question", "review", "revise", "stuck", "commit",
22 "handoff",
23];
24
25#[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#[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 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
83fn count(n: usize, one: &str, many: &str) -> String {
85 format!("{n} {}", if n == 1 { one } else { many })
86}
87
88fn 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
114fn 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 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 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 if let Some(rv) = crate::review::summary(path, Some(feature)).as_ref() {
326 let t = rv.totals;
327 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
500fn 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}