Skip to main content

speccoder_core/
actions.rs

1//! What a person can do now: one list, computed by the runtime and
2//! rendered by every interface — the dashboard's `n`, `c` and `:` today, a
3//! desktop app or another workbench later. Every action is a CLI command
4//! too, so nothing an interface offers is out of reach of a script.
5
6use serde::{Deserialize, Serialize};
7
8/// How an interface runs an action itself.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
10#[serde(tag = "kind", rename_all = "snake_case")]
11pub enum Runnable {
12    /// A planning skill (`speccoder spec <name> <arguments>`).
13    Skill {
14        /// The skill.
15        name: String,
16        /// Its arguments (the input, when one is asked for).
17        arguments: String,
18    },
19    /// `speccoder run`, the feature or one task.
20    Run {
21        /// One task, or the feature's ready tasks.
22        task: Option<String>,
23        /// This run's agent (`--agent`): an ACP preset or `native`; `None`
24        /// is the configured executor.
25        #[serde(default, skip_serializing_if = "Option::is_none")]
26        agent: Option<String>,
27        /// What this run may spend (`--cap`): `2M`, `$5`.
28        #[serde(default, skip_serializing_if = "Option::is_none")]
29        cap: Option<String>,
30    },
31    /// `speccoder run --converge`: the ready tasks, then the assessment of
32    /// the code against the spec as it is now, appending what remains.
33    Converge,
34    /// Commit exactly these planning files.
35    Commit {
36        /// Repository-relative paths.
37        paths: Vec<String>,
38    },
39    /// Approve the task's waiting call: the one that was shown, refused
40    /// when another waits now.
41    Approve {
42        /// The task.
43        task: String,
44        /// The call's id ([`crate::PendingCall::id`]).
45        call: String,
46        /// The digest of the call that was shown ([`crate::PendingCall::digest`]).
47        digest: String,
48    },
49    /// Reject it (the input is the reason), bound the same way.
50    Reject {
51        /// The task.
52        task: String,
53        /// The call's id ([`crate::PendingCall::id`]).
54        call: String,
55        /// The digest of the call that was shown ([`crate::PendingCall::digest`]).
56        digest: String,
57    },
58    /// Tick the task on the person's word (the input is the reason).
59    Accept {
60        /// The task.
61        task: String,
62    },
63    /// Skip the task (the input is the reason).
64    Skip {
65        /// The task.
66        task: String,
67    },
68    /// Leave a note for the task's next attempts (the input is the note).
69    Nudge {
70        /// The task.
71        task: String,
72    },
73    /// I did it: tick a step the person took on their word (`attested`;
74    /// the input, when given, is the reason).
75    Done {
76        /// The task.
77        task: String,
78    },
79    /// Hint & retry: a note for the task (the input), then a new attempt.
80    Hint {
81        /// The task.
82        task: String,
83    },
84    /// Rule that a held attempt's tests are right (the input is the reason).
85    Rule {
86        /// The task.
87        task: String,
88    },
89    /// Record the person's review of requirements (the input is the
90    /// comment; required to request changes).
91    Review {
92        /// The requirement identifiers; empty: every one not yet approved.
93        ids: Vec<String>,
94        /// `approve`, `request` (changes), `clear`; `answer` (ids: the
95        /// question) and `edit` / `edit-approve` (ids: the requirement) take
96        /// the input as the words.
97        verdict: String,
98    },
99    /// Tell a GitHub issue it is being specified as the current feature.
100    IssueComment {
101        /// The issue's number.
102        issue: u64,
103        /// Also assign it to the person.
104        assign: bool,
105    },
106    /// Record a quick change and, when `run`, run it.
107    QuickChange {
108        /// The change, in the person's words.
109        request: String,
110        /// When it is done: its acceptance scenarios.
111        checks: Vec<String>,
112        /// Run it once recorded.
113        run: bool,
114        /// The Ask conversation that suggested it, recorded as its origin.
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        asked: Option<String>,
117    },
118    /// Commit the changes set aside before the task's run onto the
119    /// feature's branch, as the person's own.
120    KeepSetAside {
121        /// The task.
122        task: String,
123    },
124    /// Discard them.
125    DropSetAside {
126        /// The task.
127        task: String,
128    },
129    /// Commit the setup a run stops on (an uncommitted lockfile, build
130    /// output not ignored) in the checkout.
131    FixSetup,
132    /// Pick the current feature.
133    Feature,
134    /// Push the feature's branch and open (or update) its draft pull request.
135    PullRequest,
136    /// Record a bug in the person's words (`speccoder bug`) and, when
137    /// `run`, reproduce it.
138    BugFix {
139        /// What happens (the symptom).
140        symptom: String,
141        /// What should happen instead (one or more).
142        expected: Vec<String>,
143        /// What must keep working (zero or more).
144        unchanged: Vec<String>,
145        /// The GitHub issue it comes from.
146        issue: Option<u64>,
147        /// The person's own reproduction tests, one per expected statement
148        /// (`PATH::NAME`).
149        tests: Vec<String>,
150        /// Someone else's fix to judge: a patch file or `pr:N`.
151        candidate: Option<String>,
152        /// Continue a bug the format's own bug step created.
153        slug: Option<String>,
154        /// Reproduce it once recorded.
155        run: bool,
156        /// The Ask conversation that suggested it, recorded as its origin.
157        #[serde(default, skip_serializing_if = "Option::is_none")]
158        asked: Option<String>,
159    },
160    /// Confirm (or, with `reject`, reject: the input is the reason) a
161    /// bug's reproductions, bound to the digest shown.
162    BugConfirm {
163        /// `<unit>/<task>`.
164        task: String,
165        /// The digest the person saw.
166        digest: String,
167        /// Reject instead.
168        reject: bool,
169    },
170    /// Keep an already-satisfied bug's reproductions as a regression test:
171    /// committed, the task attested (the input is the reason).
172    BugKeep {
173        /// `<unit>/<task>`.
174        task: String,
175    },
176    /// Continue a bug: reproduce again, or run its confirmed fix.
177    BugRun {
178        /// A bug of the format's own bug structure, by slug.
179        slug: Option<String>,
180        /// Or `<unit>/<task>`.
181        task: Option<String>,
182    },
183    /// Always Allow: approve the waiting command once and add exactly its
184    /// argv to `[commands] allow` (or `[sandbox] network_allow`).
185    AllowAlways {
186        /// The task.
187        task: String,
188        /// The waiting call's id.
189        call: String,
190    },
191    /// Add a prefix to a prefix list, as the confirmation of its coverage
192    /// preview (refused when the preview changed or is incomplete).
193    WidenPolicy {
194        /// Which list.
195        key: PolicyList,
196        /// The prefix (two elements or more).
197        prefix: Vec<String>,
198        /// The digest of the preview the person saw.
199        digest: String,
200    },
201    /// Decide the calls waiting in a run's batch together, each once.
202    DecideBatch {
203        /// The run.
204        run: String,
205        /// Approve or reject (the input is the reason).
206        verdict: BatchVerdict,
207        /// The listed calls decided (a non-empty subset).
208        calls: Vec<String>,
209        /// The digest of the list the person saw.
210        digest: String,
211        /// Start the run's asking budget again.
212        resume_asking: bool,
213    },
214    /// Write, reject, ask again for, edit or settle a requirement's tests
215    /// at review (the input: the reason, the comment or the content).
216    OracleTests {
217        /// The requirement (`US1`).
218        req: String,
219        /// What to do.
220        op: crate::oracle::OracleOp,
221        /// The test (reject, again, edit).
222        path: Option<String>,
223        /// The ambiguous scenario (reading).
224        scenario: Option<String>,
225        /// The reading meant (reading).
226        reading: Option<crate::oracle::Reading>,
227    },
228    /// Approve every approvable decision line, freeze the written tests and
229    /// start the feature's run, asking or on autopilot.
230    ApproveAndRun {
231        /// How the run asks.
232        mode: crate::oracle::RunMode,
233        /// The grant ids (object-bound grants) an autopilot run holds.
234        grants: Vec<String>,
235        /// The sheet's `sha256`: what the person saw approved and frozen.
236        /// Required from every interface but a terminal command.
237        #[serde(default, skip_serializing_if = "Option::is_none")]
238        sheet: Option<String>,
239    },
240    /// Add, delete or keep a review comment (the input: its words).
241    Comment {
242        /// What to do.
243        op: CommentOp,
244        /// What a new comment is about.
245        anchor: Option<crate::oracle::CommentAnchor>,
246        /// The draft (delete, keep).
247        id: Option<String>,
248    },
249    /// Send the comments as one revision turn.
250    CommentsSend {
251        /// The drafts the person saw (empty: every draft in the tray); a
252        /// draft sent already refuses the send.
253        ids: Vec<String>,
254    },
255    /// Undo the last revision turn: the documents back, byte for byte.
256    RevisionUndo {
257        /// The turn the person saw.
258        turn: Option<String>,
259    },
260    /// Code-view line comments as one nudge for the task's next attempt.
261    CodeNudge {
262        /// The task.
263        task: String,
264        /// The comments.
265        comments: Vec<crate::oracle::CodeComment>,
266    },
267    /// Write a story's acceptance tests at a Setup or Foundational task
268    /// (`revoke`: take the decision back) — a decision recorded in the
269    /// state directory, never an edit of the spec.
270    AcceptanceAt {
271        /// The feature.
272        feature: String,
273        /// The story (`US1`).
274        story: String,
275        /// The task.
276        task: String,
277        /// Take the decision back.
278        revoke: bool,
279    },
280    /// Check a story: its oracle written when it has none, run once on the
281    /// feature's branch.
282    CheckStory {
283        /// The story (`US3`).
284        story: String,
285    },
286    /// Rule that a task's tests are wrong (the input is the reason): one
287    /// scenario's oracle tests, or — none named — the task's own test
288    /// outside a story. The next run writes them again.
289    RuleScenario {
290        /// The task that met them.
291        task: String,
292        /// The scenario (`US3-2`); none: the task's own test.
293        scenario: Option<String>,
294    },
295    /// Accept a scenario its test author called untestable (the input is
296    /// the reason): its story's checkpoint no longer waits for it, and the
297    /// tick records who accepted it.
298    AcceptUntestable {
299        /// The scenario (`US2-2`).
300        scenario: String,
301    },
302    /// Install the bundled `speccoder` Spec Kit preset through Spec Kit's
303    /// own `specify preset add` (`update`: the installed one, replaced).
304    PresetAdd {
305        /// Replace an installed older version.
306        update: bool,
307    },
308    /// Plan a new feature through the format's own skills — the creating
309    /// skill, then toward the task list, then the check — stopping where a
310    /// person decides (`speccoder spec <skill> --through <t>`).
311    PlanThrough {
312        /// A new description, or the continuation after a stop.
313        from: PlanFrom,
314        /// How far.
315        through: Through,
316    },
317    /// Make `target` a project: the plan the person saw, bound to its digest
318    /// (`speccoder new`).
319    NewProject {
320        /// The folder, as the plan names it.
321        target: String,
322        /// What the person chose.
323        choices: crate::new_project::NewChoices,
324        /// The digest of the plan on screen.
325        plan_digest: String,
326    },
327    /// Undo the project speccoder created at the dispatch's root
328    /// (`speccoder new --undo`): to the Trash, or a revert of its commit.
329    UndoNewProject {
330        /// speccoder's commit, as recorded.
331        commit: String,
332    },
333    /// Clone a repository of the person's with their git (their credentials).
334    CloneRepository {
335        /// `owner/repo`.
336        repo: String,
337        /// The destination folder.
338        target: String,
339    },
340    /// Copy the bundled sample into the state directory and replay its
341    /// recorded run strictly (`speccoder sample`).
342    OpenSample {
343        /// Replace an existing copy.
344        fresh: bool,
345    },
346    /// Run the sample's own checks, offline, in its copy (the sample's
347    /// end card, *Run the checks yourself*).
348    SampleChecks,
349    /// Decline a promotion offer: it returns only when a signal that did
350    /// not hold now holds.
351    DeclinePromotion {
352        /// `<unit>/<task>`.
353        task: String,
354    },
355    /// Explore an idea read-only, ending in approaches to choose from; or
356    /// resume a session with more words.
357    Explore {
358        /// The idea (a new session) or the words added (a resumed one).
359        idea: String,
360        /// The skill to host instead of the format's own (`--skill`).
361        #[serde(default)]
362        skill: Option<String>,
363        /// The session to resume.
364        #[serde(default)]
365        resume: Option<String>,
366    },
367    /// The person's choice in a session: an approach, and the constraints
368    /// and open questions they keep or add. It composes the brief.
369    ExploreChoose {
370        /// The session (`ex-…`).
371        session: String,
372        /// 1-based, of the session's last approaches.
373        approach: usize,
374        /// The constraints, as kept and added.
375        #[serde(default)]
376        constraints: Vec<String>,
377        /// The open questions, as kept and added.
378        #[serde(default)]
379        questions: Vec<String>,
380        /// Chosen by a flag (`--choose N`), not in a prompt or the app.
381        #[serde(default)]
382        flag: bool,
383    },
384}
385
386/// How far plan through goes.
387#[derive(
388    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
389)]
390#[serde(rename_all = "snake_case")]
391pub enum Through {
392    /// The spec only.
393    Spec,
394    /// The spec and the plan.
395    Plan,
396    /// The spec, the plan and the tasks.
397    Tasks,
398    /// Then the format's check of the planning files.
399    Analyze,
400}
401
402impl Through {
403    /// Every value, in order.
404    pub const ALL: [Self; 4] = [Self::Spec, Self::Plan, Self::Tasks, Self::Analyze];
405
406    /// Its name (`tasks`).
407    #[must_use]
408    pub const fn as_str(self) -> &'static str {
409        match self {
410            Self::Spec => "spec",
411            Self::Plan => "plan",
412            Self::Tasks => "tasks",
413            Self::Analyze => "analyze",
414        }
415    }
416
417    /// The value named `s`.
418    #[must_use]
419    pub fn parse(s: &str) -> Option<Self> {
420        Self::ALL.into_iter().find(|t| t.as_str() == s)
421    }
422}
423
424/// Where a new feature's description comes from. Later rungs add theirs
425/// (an Explore brief, a promoted quick change) when they are built.
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
427#[serde(tag = "kind", rename_all = "snake_case")]
428pub enum FeatureSource {
429    /// The person's own words.
430    Description,
431    /// A GitHub issue's description, verbatim.
432    Issue {
433        /// Its number.
434        number: u64,
435    },
436    /// A quick change or bug that grew: its requirement verbatim, and the
437    /// Ask conversations the person chose to carry.
438    Promotion {
439        /// `<unit>/<task>`.
440        task: String,
441        /// Conversation ids (`c-…`).
442        #[serde(default)]
443        conversations: Vec<String>,
444    },
445    /// An Explore session's brief, verbatim.
446    Explore {
447        /// The session (`ex-…`).
448        session: String,
449        /// The brief's digest, checked before anything runs.
450        digest: String,
451    },
452}
453
454/// What plan through starts from.
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
456#[serde(tag = "kind", rename_all = "snake_case")]
457pub enum PlanFrom {
458    /// A new unit from a description.
459    Create {
460        /// What it should do, verbatim.
461        description: String,
462        /// Where the description comes from.
463        source: FeatureSource,
464    },
465    /// The next skill after a stop, derived from the unit's files.
466    Continue,
467}
468
469/// What a comment action does.
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
471#[serde(rename_all = "snake_case")]
472pub enum CommentOp {
473    /// A new draft.
474    Add,
475    /// Remove a draft.
476    Delete,
477    /// Keep an outdated draft.
478    Keep,
479}
480
481/// A prefix list in `speccoder.toml`.
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
483#[serde(rename_all = "snake_case")]
484pub enum PolicyList {
485    /// `[commands] allow_prefix`.
486    CommandsPrefix,
487    /// `[sandbox] network_allow_prefix`.
488    NetworkPrefix,
489}
490
491impl PolicyList {
492    /// Its name as serialized.
493    #[must_use]
494    pub const fn as_str(self) -> &'static str {
495        match self {
496            Self::CommandsPrefix => "commands_prefix",
497            Self::NetworkPrefix => "network_prefix",
498        }
499    }
500
501    /// The configuration key it is (`commands.allow_prefix`).
502    #[must_use]
503    pub const fn key(self) -> &'static str {
504        match self {
505            Self::CommandsPrefix => "commands.allow_prefix",
506            Self::NetworkPrefix => "sandbox.network_allow_prefix",
507        }
508    }
509
510    /// The list for a configuration key.
511    #[must_use]
512    pub fn of_key(key: &str) -> Option<Self> {
513        match key {
514            "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
515            "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
516            _ => None,
517        }
518    }
519}
520
521/// A batch decision.
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
523#[serde(rename_all = "snake_case")]
524pub enum BatchVerdict {
525    /// Each listed call once.
526    Approve,
527    /// Each listed call, with one reason.
528    Reject,
529}
530
531/// What kind of action it is: one per [`Runnable`] kind (a run split by
532/// whether it names a task), plus [`ActionKind::Command`] for an action an
533/// interface does not run itself. Exhaustive at any one version; a new
534/// kind arrives with its `Runnable` variant (the *adding a kind* contract
535/// of `speccoder_runtime::actions::perform`).
536#[derive(
537    Debug,
538    Clone,
539    Copy,
540    PartialEq,
541    Eq,
542    Hash,
543    PartialOrd,
544    Ord,
545    Serialize,
546    Deserialize,
547    schemars::JsonSchema,
548)]
549#[serde(rename_all = "snake_case")]
550pub enum ActionKind {
551    /// [`Runnable::Skill`].
552    Skill,
553    /// [`Runnable::Run`] with a task.
554    RunTask,
555    /// [`Runnable::Run`] without one: the feature.
556    RunFeature,
557    /// [`Runnable::Converge`].
558    Converge,
559    /// [`Runnable::Commit`].
560    Commit,
561    /// [`Runnable::Approve`].
562    Approve,
563    /// [`Runnable::Reject`].
564    Reject,
565    /// [`Runnable::Accept`].
566    Accept,
567    /// [`Runnable::Skip`].
568    Skip,
569    /// [`Runnable::Nudge`].
570    Nudge,
571    /// [`Runnable::Done`].
572    Done,
573    /// [`Runnable::Hint`].
574    Hint,
575    /// [`Runnable::Rule`].
576    Rule,
577    /// [`Runnable::Review`].
578    Review,
579    /// [`Runnable::IssueComment`].
580    IssueComment,
581    /// [`Runnable::QuickChange`].
582    QuickChange,
583    /// [`Runnable::KeepSetAside`].
584    KeepSetAside,
585    /// [`Runnable::DropSetAside`].
586    DropSetAside,
587    /// [`Runnable::FixSetup`].
588    FixSetup,
589    /// [`Runnable::Feature`].
590    Feature,
591    /// [`Runnable::PullRequest`].
592    PullRequest,
593    /// [`Runnable::BugFix`].
594    BugFix,
595    /// [`Runnable::BugConfirm`].
596    BugConfirm,
597    /// [`Runnable::BugRun`].
598    BugRun,
599    /// [`Runnable::BugKeep`].
600    BugKeep,
601    /// [`Runnable::AllowAlways`].
602    AllowAlways,
603    /// [`Runnable::WidenPolicy`].
604    WidenPolicy,
605    /// [`Runnable::DecideBatch`].
606    DecideBatch,
607    /// [`Runnable::OracleTests`].
608    OracleTests,
609    /// [`Runnable::ApproveAndRun`].
610    ApproveAndRun,
611    /// [`Runnable::Comment`].
612    Comment,
613    /// [`Runnable::CommentsSend`].
614    CommentsSend,
615    /// [`Runnable::RevisionUndo`].
616    RevisionUndo,
617    /// [`Runnable::CodeNudge`].
618    CodeNudge,
619    /// [`Runnable::AcceptanceAt`].
620    AcceptanceAt,
621    /// [`Runnable::RuleScenario`].
622    RuleScenario,
623    /// [`Runnable::CheckStory`].
624    CheckStory,
625    /// [`Runnable::AcceptUntestable`].
626    AcceptUntestable,
627    /// [`Runnable::PresetAdd`].
628    PresetAdd,
629    /// [`Runnable::PlanThrough`].
630    PlanThrough,
631    /// [`Runnable::NewProject`].
632    NewProject,
633    /// [`Runnable::UndoNewProject`].
634    UndoNewProject,
635    /// [`Runnable::CloneRepository`].
636    CloneRepository,
637    /// [`Runnable::OpenSample`].
638    OpenSample,
639    /// [`Runnable::SampleChecks`].
640    SampleChecks,
641    /// [`Runnable::DeclinePromotion`].
642    DeclinePromotion,
643    /// [`Runnable::Explore`].
644    Explore,
645    /// [`Runnable::ExploreChoose`].
646    ExploreChoose,
647    /// No runnable: the person runs the command.
648    Command,
649}
650
651impl ActionKind {
652    /// Every kind, in declaration order (a test holds it to the enum).
653    pub const ALL: &'static [Self] = &[
654        Self::Skill,
655        Self::RunTask,
656        Self::RunFeature,
657        Self::Converge,
658        Self::Commit,
659        Self::Approve,
660        Self::Reject,
661        Self::Accept,
662        Self::Skip,
663        Self::Nudge,
664        Self::Done,
665        Self::Hint,
666        Self::Rule,
667        Self::Review,
668        Self::IssueComment,
669        Self::QuickChange,
670        Self::KeepSetAside,
671        Self::DropSetAside,
672        Self::FixSetup,
673        Self::Feature,
674        Self::PullRequest,
675        Self::BugFix,
676        Self::BugConfirm,
677        Self::BugRun,
678        Self::BugKeep,
679        Self::AllowAlways,
680        Self::WidenPolicy,
681        Self::DecideBatch,
682        Self::OracleTests,
683        Self::ApproveAndRun,
684        Self::Comment,
685        Self::CommentsSend,
686        Self::RevisionUndo,
687        Self::CodeNudge,
688        Self::AcceptanceAt,
689        Self::RuleScenario,
690        Self::CheckStory,
691        Self::AcceptUntestable,
692        Self::PresetAdd,
693        Self::PlanThrough,
694        Self::NewProject,
695        Self::UndoNewProject,
696        Self::CloneRepository,
697        Self::OpenSample,
698        Self::SampleChecks,
699        Self::DeclinePromotion,
700        Self::Explore,
701        Self::ExploreChoose,
702        Self::Command,
703    ];
704
705    /// Its name as serialized (`run_task`).
706    #[must_use]
707    pub const fn as_str(self) -> &'static str {
708        match self {
709            Self::Skill => "skill",
710            Self::RunTask => "run_task",
711            Self::RunFeature => "run_feature",
712            Self::Converge => "converge",
713            Self::Commit => "commit",
714            Self::Approve => "approve",
715            Self::Reject => "reject",
716            Self::Accept => "accept",
717            Self::Skip => "skip",
718            Self::Nudge => "nudge",
719            Self::Done => "done",
720            Self::Hint => "hint",
721            Self::Rule => "rule",
722            Self::Review => "review",
723            Self::IssueComment => "issue_comment",
724            Self::QuickChange => "quick_change",
725            Self::KeepSetAside => "keep_set_aside",
726            Self::DropSetAside => "drop_set_aside",
727            Self::FixSetup => "fix_setup",
728            Self::Feature => "feature",
729            Self::PullRequest => "pull_request",
730            Self::BugFix => "bug_fix",
731            Self::BugConfirm => "bug_confirm",
732            Self::BugRun => "bug_run",
733            Self::BugKeep => "bug_keep",
734            Self::AllowAlways => "allow_always",
735            Self::WidenPolicy => "widen_policy",
736            Self::DecideBatch => "decide_batch",
737            Self::OracleTests => "oracle_tests",
738            Self::ApproveAndRun => "approve_and_run",
739            Self::Comment => "comment",
740            Self::CommentsSend => "comments_send",
741            Self::RevisionUndo => "revision_undo",
742            Self::CodeNudge => "code_nudge",
743            Self::AcceptanceAt => "acceptance_at",
744            Self::RuleScenario => "rule_scenario",
745            Self::CheckStory => "check_story",
746            Self::AcceptUntestable => "accept_untestable",
747            Self::PresetAdd => "preset_add",
748            Self::PlanThrough => "plan_through",
749            Self::NewProject => "new_project",
750            Self::UndoNewProject => "undo_new_project",
751            Self::CloneRepository => "clone_repository",
752            Self::OpenSample => "open_sample",
753            Self::SampleChecks => "sample_checks",
754            Self::DeclinePromotion => "decline_promotion",
755            Self::Explore => "explore",
756            Self::ExploreChoose => "explore_choose",
757            Self::Command => "command",
758        }
759    }
760
761    /// One runnable of this kind (`None` for [`ActionKind::Command`]): what
762    /// tests and documentation enumerate the kinds by.
763    #[must_use]
764    pub fn example(self) -> Option<Runnable> {
765        let task = || "T001".to_owned();
766        Some(match self {
767            Self::Skill => Runnable::Skill {
768                name: "plan".to_owned(),
769                arguments: String::new(),
770            },
771            Self::RunTask => Runnable::Run {
772                task: Some(task()),
773                agent: None,
774                cap: None,
775            },
776            Self::RunFeature => Runnable::Run {
777                task: None,
778                agent: None,
779                cap: None,
780            },
781            Self::Converge => Runnable::Converge,
782            Self::Commit => Runnable::Commit {
783                paths: vec!["plan.md".to_owned()],
784            },
785            Self::Approve => Runnable::Approve {
786                task: task(),
787                call: "0f3c".to_owned(),
788                digest: "d1".to_owned(),
789            },
790            Self::Reject => Runnable::Reject {
791                task: task(),
792                call: "0f3c".to_owned(),
793                digest: "d1".to_owned(),
794            },
795            Self::Accept => Runnable::Accept { task: task() },
796            Self::Skip => Runnable::Skip { task: task() },
797            Self::Nudge => Runnable::Nudge { task: task() },
798            Self::Done => Runnable::Done { task: task() },
799            Self::Hint => Runnable::Hint { task: task() },
800            Self::Rule => Runnable::Rule { task: task() },
801            Self::Review => Runnable::Review {
802                ids: vec!["FR-001".to_owned()],
803                verdict: "approve".to_owned(),
804            },
805            Self::IssueComment => Runnable::IssueComment {
806                issue: 42,
807                assign: false,
808            },
809            Self::QuickChange => Runnable::QuickChange {
810                request: "a change".to_owned(),
811                checks: Vec::new(),
812                run: false,
813                asked: None,
814            },
815            Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
816            Self::DropSetAside => Runnable::DropSetAside { task: task() },
817            Self::FixSetup => Runnable::FixSetup,
818            Self::Feature => Runnable::Feature,
819            Self::PullRequest => Runnable::PullRequest,
820            Self::BugFix => Runnable::BugFix {
821                symptom: "saving an empty title crashes".to_owned(),
822                expected: vec!["an empty title is refused".to_owned()],
823                unchanged: Vec::new(),
824                issue: None,
825                tests: Vec::new(),
826                candidate: None,
827                slug: None,
828                run: false,
829                asked: None,
830            },
831            Self::BugConfirm => Runnable::BugConfirm {
832                task: "007-bugfixes/T001".to_owned(),
833                digest: "sha256:0".to_owned(),
834                reject: false,
835            },
836            Self::BugRun => Runnable::BugRun {
837                slug: None,
838                task: Some("007-bugfixes/T001".to_owned()),
839            },
840            Self::BugKeep => Runnable::BugKeep {
841                task: "007-bugfixes/T001".to_owned(),
842            },
843            Self::AllowAlways => Runnable::AllowAlways {
844                task: task(),
845                call: "0f3c".to_owned(),
846            },
847            Self::WidenPolicy => Runnable::WidenPolicy {
848                key: PolicyList::CommandsPrefix,
849                prefix: vec!["npm".to_owned(), "run".to_owned()],
850                digest: "d1".to_owned(),
851            },
852            Self::DecideBatch => Runnable::DecideBatch {
853                run: "run-1-0a0b0c0d".to_owned(),
854                verdict: BatchVerdict::Approve,
855                calls: vec!["0f3c".to_owned()],
856                digest: "d1".to_owned(),
857                resume_asking: false,
858            },
859            Self::OracleTests => Runnable::OracleTests {
860                req: "US1".to_owned(),
861                op: crate::oracle::OracleOp::Write,
862                path: None,
863                scenario: None,
864                reading: None,
865            },
866            Self::ApproveAndRun => Runnable::ApproveAndRun {
867                mode: crate::oracle::RunMode::Ask,
868                grants: Vec::new(),
869                sheet: None,
870            },
871            Self::Comment => Runnable::Comment {
872                op: CommentOp::Delete,
873                anchor: None,
874                id: Some("c1".to_owned()),
875            },
876            Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
877            Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
878            Self::CodeNudge => Runnable::CodeNudge {
879                task: task(),
880                comments: vec![crate::oracle::CodeComment {
881                    path: "src/lib.rs".to_owned(),
882                    line: 1,
883                    quote: "pub fn ok() {}".to_owned(),
884                    text: "name it".to_owned(),
885                }],
886            },
887            Self::AcceptanceAt => Runnable::AcceptanceAt {
888                feature: "001-greet-function".to_owned(),
889                story: "US1".to_owned(),
890                task: "T003".to_owned(),
891                revoke: false,
892            },
893            Self::CheckStory => Runnable::CheckStory {
894                story: "US3".to_owned(),
895            },
896            Self::AcceptUntestable => Runnable::AcceptUntestable {
897                scenario: "US2-2".to_owned(),
898            },
899            Self::RuleScenario => Runnable::RuleScenario {
900                task: "T021".to_owned(),
901                scenario: Some("US3-2".to_owned()),
902            },
903            Self::PresetAdd => Runnable::PresetAdd { update: false },
904            Self::PlanThrough => Runnable::PlanThrough {
905                from: PlanFrom::Create {
906                    description: "Users can export a report as CSV".to_owned(),
907                    source: FeatureSource::Description,
908                },
909                through: Through::Tasks,
910            },
911            Self::NewProject => Runnable::NewProject {
912                target: "/tmp/md2csv".to_owned(),
913                choices: crate::new_project::NewChoices {
914                    idea: "a CLI that turns Markdown tables into CSV".to_owned(),
915                    ..crate::new_project::NewChoices::default()
916                },
917                plan_digest: "sha256:0".to_owned(),
918            },
919            Self::UndoNewProject => Runnable::UndoNewProject {
920                commit: "1a2b3c4".to_owned(),
921            },
922            Self::CloneRepository => Runnable::CloneRepository {
923                repo: "octo/hello".to_owned(),
924                target: "/tmp/hello".to_owned(),
925            },
926            Self::OpenSample => Runnable::OpenSample { fresh: false },
927            Self::SampleChecks => Runnable::SampleChecks,
928            Self::DeclinePromotion => Runnable::DeclinePromotion {
929                task: "004-quick-changes/T002".to_owned(),
930            },
931            Self::Explore => Runnable::Explore {
932                idea: "export reports as CSV".to_owned(),
933                skill: None,
934                resume: None,
935            },
936            Self::ExploreChoose => Runnable::ExploreChoose {
937                session: "ex-1a2b3c4d5e6f".to_owned(),
938                approach: 2,
939                constraints: vec!["no new dependency".to_owned()],
940                questions: Vec::new(),
941                flag: false,
942            },
943            Self::Command => return None,
944        })
945    }
946}
947
948impl Runnable {
949    /// One runnable of every kind ([`ActionKind::example`]).
950    #[must_use]
951    pub fn examples() -> Vec<Self> {
952        ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
953    }
954
955    /// Its kind.
956    #[must_use]
957    pub const fn kind(&self) -> ActionKind {
958        match self {
959            Self::Skill { .. } => ActionKind::Skill,
960            Self::Run { task: Some(_), .. } => ActionKind::RunTask,
961            Self::Run { task: None, .. } => ActionKind::RunFeature,
962            Self::Converge => ActionKind::Converge,
963            Self::Commit { .. } => ActionKind::Commit,
964            Self::Approve { .. } => ActionKind::Approve,
965            Self::Reject { .. } => ActionKind::Reject,
966            Self::Accept { .. } => ActionKind::Accept,
967            Self::Skip { .. } => ActionKind::Skip,
968            Self::Nudge { .. } => ActionKind::Nudge,
969            Self::Done { .. } => ActionKind::Done,
970            Self::Hint { .. } => ActionKind::Hint,
971            Self::Rule { .. } => ActionKind::Rule,
972            Self::Review { .. } => ActionKind::Review,
973            Self::IssueComment { .. } => ActionKind::IssueComment,
974            Self::QuickChange { .. } => ActionKind::QuickChange,
975            Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
976            Self::DropSetAside { .. } => ActionKind::DropSetAside,
977            Self::FixSetup => ActionKind::FixSetup,
978            Self::Feature => ActionKind::Feature,
979            Self::PullRequest => ActionKind::PullRequest,
980            Self::BugFix { .. } => ActionKind::BugFix,
981            Self::BugConfirm { .. } => ActionKind::BugConfirm,
982            Self::BugRun { .. } => ActionKind::BugRun,
983            Self::BugKeep { .. } => ActionKind::BugKeep,
984            Self::AllowAlways { .. } => ActionKind::AllowAlways,
985            Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
986            Self::DecideBatch { .. } => ActionKind::DecideBatch,
987            Self::OracleTests { .. } => ActionKind::OracleTests,
988            Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
989            Self::Comment { .. } => ActionKind::Comment,
990            Self::CommentsSend { .. } => ActionKind::CommentsSend,
991            Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
992            Self::CodeNudge { .. } => ActionKind::CodeNudge,
993            Self::AcceptanceAt { .. } => ActionKind::AcceptanceAt,
994            Self::RuleScenario { .. } => ActionKind::RuleScenario,
995            Self::CheckStory { .. } => ActionKind::CheckStory,
996            Self::AcceptUntestable { .. } => ActionKind::AcceptUntestable,
997            Self::PresetAdd { .. } => ActionKind::PresetAdd,
998            Self::PlanThrough { .. } => ActionKind::PlanThrough,
999            Self::NewProject { .. } => ActionKind::NewProject,
1000            Self::UndoNewProject { .. } => ActionKind::UndoNewProject,
1001            Self::CloneRepository { .. } => ActionKind::CloneRepository,
1002            Self::OpenSample { .. } => ActionKind::OpenSample,
1003            Self::SampleChecks => ActionKind::SampleChecks,
1004            Self::DeclinePromotion { .. } => ActionKind::DeclinePromotion,
1005            Self::Explore { .. } => ActionKind::Explore,
1006            Self::ExploreChoose { .. } => ActionKind::ExploreChoose,
1007        }
1008    }
1009}
1010
1011/// What an action is, independent of its wording: its kind and what it
1012/// is about (the task, the skill, the issue). An interface finds an action
1013/// by this, never by its label.
1014#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1015pub struct ActionId {
1016    /// Its kind.
1017    pub kind: ActionKind,
1018    /// What it is about: a task's id, a skill's name, an issue's number,
1019    /// a review's `verdict` (`verdict:first id`), a command's words.
1020    pub subject: Option<String>,
1021}
1022
1023/// The identity of the action that runs `run` (or, without one, stands
1024/// for `command`): derived, so it cannot disagree with what runs.
1025#[must_use]
1026pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
1027    let Some(run) = run else {
1028        // The command's words: `speccoder attest verify` → `attest verify`.
1029        let words: Vec<&str> = command
1030            .split_whitespace()
1031            .skip_while(|w| *w == "speccoder")
1032            .take_while(|w| {
1033                w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1034                    && w.chars()
1035                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1036            })
1037            .collect();
1038        return ActionId {
1039            kind: ActionKind::Command,
1040            subject: (!words.is_empty()).then(|| words.join(" ")),
1041        };
1042    };
1043    let subject = match run {
1044        Runnable::Skill { name, .. } => Some(name.clone()),
1045        Runnable::Run { task, .. } => task.clone(),
1046        Runnable::Approve { task, .. }
1047        | Runnable::Reject { task, .. }
1048        | Runnable::Accept { task }
1049        | Runnable::Skip { task }
1050        | Runnable::Nudge { task }
1051        | Runnable::Done { task }
1052        | Runnable::Hint { task }
1053        | Runnable::Rule { task }
1054        | Runnable::KeepSetAside { task }
1055        | Runnable::DropSetAside { task }
1056        | Runnable::AllowAlways { task, .. }
1057        | Runnable::CodeNudge { task, .. }
1058        | Runnable::AcceptanceAt { task, .. }
1059        | Runnable::RuleScenario { task, .. }
1060        | Runnable::DeclinePromotion { task } => Some(task.clone()),
1061        Runnable::Explore { resume, .. } => resume.clone(),
1062        Runnable::ExploreChoose { session, .. } => Some(session.clone()),
1063        Runnable::OracleTests { req, .. } => Some(req.clone()),
1064        Runnable::CheckStory { story } => Some(story.clone()),
1065        Runnable::AcceptUntestable { scenario } => Some(scenario.clone()),
1066        Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
1067        Runnable::Comment { id, .. } => id.clone(),
1068        Runnable::RevisionUndo { turn } => turn.clone(),
1069        Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
1070        Runnable::DecideBatch { run, .. } => Some(run.clone()),
1071        Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
1072        Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
1073        Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
1074        Runnable::BugFix { slug, issue, .. } => {
1075            slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
1076        }
1077        Runnable::Review { ids, verdict } => Some(match ids.first() {
1078            Some(id) => format!("{verdict}:{id}"),
1079            None => verdict.clone(),
1080        }),
1081        Runnable::PlanThrough { from, through } => Some(match from {
1082            PlanFrom::Create { .. } => format!("create:{}", through.as_str()),
1083            PlanFrom::Continue => format!("continue:{}", through.as_str()),
1084        }),
1085        Runnable::NewProject { target, .. } => Some(target.clone()),
1086        Runnable::UndoNewProject { commit } => Some(commit.clone()),
1087        Runnable::CloneRepository { repo, .. } => Some(repo.clone()),
1088        Runnable::Converge
1089        | Runnable::OpenSample { .. }
1090        | Runnable::SampleChecks
1091        | Runnable::Commit { .. }
1092        | Runnable::QuickChange { .. }
1093        | Runnable::FixSetup
1094        | Runnable::Feature
1095        | Runnable::PullRequest
1096        | Runnable::PresetAdd { .. }
1097        | Runnable::CommentsSend { .. } => None,
1098    };
1099    ActionId {
1100        kind: run.kind(),
1101        subject,
1102    }
1103}
1104
1105/// The part a planning skill plays in its format's planning, from the
1106/// format's adapter: what an interface finds "the create step" or "the
1107/// analysis step" by, without naming a format's skill.
1108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
1109#[serde(rename_all = "snake_case")]
1110pub enum SkillRole {
1111    /// It creates a unit (Spec Kit's `specify`).
1112    Create,
1113    /// It leads toward the task list (`plan`, `tasks`).
1114    TowardTasks,
1115    /// It revises a written spec (`clarify`).
1116    Revise,
1117    /// It checks the planning files for consistency (`analyze`).
1118    Check,
1119    /// Anything else.
1120    Other,
1121}
1122
1123/// The interface a person acted through, as a write records it (`via`):
1124/// one vocabulary for every record. Records written before it named
1125/// `cli`, `tui` or `dashboard` (read as [`Via::Terminal`]) and
1126/// `editor <name>` (read as [`Via::Acp`], the name a separate client).
1127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
1128#[serde(rename_all = "snake_case")]
1129pub enum Via {
1130    /// A terminal: the line CLI, its prompts, the dashboard.
1131    #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
1132    Terminal,
1133    /// The web app, in a browser tab or the desktop window.
1134    Web,
1135    /// The desktop window, once it can tell itself from a tab.
1136    Window,
1137    /// An editor over the Agent Client Protocol.
1138    Acp,
1139    /// A phone.
1140    Phone,
1141    /// Continuous integration.
1142    Ci,
1143    /// A client over the Model Context Protocol.
1144    Mcp,
1145}
1146
1147impl Via {
1148    /// Its recorded value (`terminal`).
1149    #[must_use]
1150    pub const fn as_str(self) -> &'static str {
1151        match self {
1152            Self::Terminal => "terminal",
1153            Self::Web => "web",
1154            Self::Window => "window",
1155            Self::Acp => "acp",
1156            Self::Phone => "phone",
1157            Self::Ci => "ci",
1158            Self::Mcp => "mcp",
1159        }
1160    }
1161
1162    /// A recorded value, read back with the client it names (`editor Zed`
1163    /// → `acp`, `Zed`); `None` for a value no interface ever recorded.
1164    #[must_use]
1165    pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
1166        if let Some(client) = recorded.strip_prefix("editor ") {
1167            return Some((Self::Acp, Some(client.to_owned())));
1168        }
1169        let via = match recorded {
1170            "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
1171            "web" => Self::Web,
1172            "window" => Self::Window,
1173            "acp" | "editor" => Self::Acp,
1174            "phone" => Self::Phone,
1175            "ci" => Self::Ci,
1176            "mcp" => Self::Mcp,
1177            _ => return None,
1178        };
1179        Some((via, None))
1180    }
1181}
1182
1183impl std::fmt::Display for Via {
1184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1185        f.write_str(self.as_str())
1186    }
1187}
1188
1189/// One thing a person can do now.
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1191pub struct Action {
1192    /// `next`, `plan`, `work`, `task`, `feature` or `review`.
1193    pub group: String,
1194    /// What it does, in a few words.
1195    pub label: String,
1196    /// Why, or what follows (shown beside it).
1197    pub why: String,
1198    /// The equivalent command; `"…"` marks the input.
1199    pub command: String,
1200    /// How an interface runs it; `None`: see `step`.
1201    pub run: Option<Runnable>,
1202    /// How an interface takes it when it does not run it (`run` is `None`):
1203    /// a view it opens, the person's own command, a line for their shell.
1204    pub step: Option<crate::step::How>,
1205    /// Why it cannot run now; `None`: it can.
1206    pub blocked: Option<String>,
1207    /// What to ask for before it runs (`Describe the feature`).
1208    pub input: Option<String>,
1209    /// It leaves the machine (a push, a pull request) or cannot be undone
1210    /// (a discard): what an interface says in a confirmation before it runs it.
1211    pub confirm: Option<String>,
1212    /// What it is, independent of its wording ([`identity`]).
1213    pub id: ActionId,
1214    /// A planning skill's part in its format's planning; `None` for
1215    /// everything else.
1216    pub role: Option<SkillRole>,
1217}
1218
1219impl Action {
1220    /// An action that stands for `command`, which the person runs.
1221    #[must_use]
1222    pub fn new(
1223        group: &str,
1224        label: impl Into<String>,
1225        why: impl Into<String>,
1226        command: impl Into<String>,
1227    ) -> Self {
1228        let command = command.into();
1229        Self {
1230            group: group.to_owned(),
1231            label: label.into(),
1232            why: why.into(),
1233            id: identity(None, &command),
1234            step: Some(crate::step::step_of(&command, crate::step::Context::default()).how),
1235            command,
1236            run: None,
1237            blocked: None,
1238            input: None,
1239            confirm: None,
1240            role: None,
1241        }
1242    }
1243
1244    /// Run by an interface as `run`; its identity follows.
1245    #[must_use]
1246    pub fn with_run(mut self, run: Runnable) -> Self {
1247        self.id = identity(Some(&run), &self.command);
1248        self.run = Some(run);
1249        self.step = None;
1250        self
1251    }
1252
1253    /// A planning skill playing `role`.
1254    #[must_use]
1255    pub const fn with_role(mut self, role: SkillRole) -> Self {
1256        self.role = Some(role);
1257        self
1258    }
1259
1260    /// It can be run from an interface, now.
1261    #[must_use]
1262    pub const fn runnable(&self) -> bool {
1263        self.run.is_some() && self.blocked.is_none()
1264    }
1265}
1266
1267/// The primary action: the first runnable one of the `next` group.
1268#[must_use]
1269pub fn primary(actions: &[Action]) -> Option<&Action> {
1270    actions.iter().find(|a| a.group == "next" && a.runnable())
1271}
1272
1273/// Where a step of the journey is.
1274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1275#[serde(rename_all = "snake_case")]
1276pub enum StepState {
1277    /// Done.
1278    Done,
1279    /// The one to take now.
1280    Now,
1281    /// Later.
1282    Todo,
1283    /// Passed without its file: a task list written without a spec or a
1284    /// plan. Not a step left to take.
1285    Skipped,
1286}
1287
1288/// One step of a feature's journey: `spec`, `plan`, `tasks`, `commit`,
1289/// `run`, `review`.
1290#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
1291pub struct JourneyStep {
1292    /// Its name.
1293    pub name: String,
1294    /// Where it is.
1295    pub state: StepState,
1296}
1297
1298/// Facts the journey is derived from.
1299#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1300pub struct JourneyFacts {
1301    /// `spec.md` exists.
1302    pub spec: bool,
1303    /// `plan.md` exists.
1304    pub plan: bool,
1305    /// The task list exists.
1306    pub tasks: bool,
1307    /// Planning files are not committed.
1308    pub uncommitted: bool,
1309    /// Tasks in the list.
1310    pub total: usize,
1311    /// Of them ticked or skipped.
1312    pub finished: usize,
1313}
1314
1315/// The journey from a spec to a reviewed branch, and where the feature is
1316/// on it: everything before the first open step is done (or skipped, when a
1317/// later planning file exists without it), that one is now.
1318#[must_use]
1319pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
1320    let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
1321    let done = [
1322        ("spec", f.spec),
1323        ("plan", f.plan),
1324        // A task list without a task is not written yet.
1325        ("tasks", f.tasks && f.total > 0),
1326        ("commit", f.tasks && f.total > 0 && !f.uncommitted),
1327        ("run", f.total > 0 && f.finished == f.total),
1328        ("review", false),
1329    ];
1330    let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
1331    let now = done
1332        .iter()
1333        .enumerate()
1334        .position(|(i, (_, d))| !d && !passed(i))
1335        .unwrap_or(done.len());
1336    done.iter()
1337        .enumerate()
1338        .map(|(i, (name, d))| JourneyStep {
1339            name: (*name).to_owned(),
1340            state: if *d && i < now {
1341                StepState::Done
1342            } else if passed(i) && i < now {
1343                StepState::Skipped
1344            } else if i == now {
1345                StepState::Now
1346            } else {
1347                StepState::Todo
1348            },
1349        })
1350        .collect()
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355    use super::*;
1356
1357    #[test]
1358    fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
1359        // The schema lists every variant the compiler knows.
1360        let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
1361            .unwrap_or_else(|e| panic!("{e}"));
1362        let mut named: Vec<String> = schema["oneOf"]
1363            .as_array()
1364            .into_iter()
1365            .flatten()
1366            .filter_map(|v| v["const"].as_str().map(str::to_owned))
1367            .collect();
1368        named.sort();
1369        let mut listed: Vec<String> = ActionKind::ALL
1370            .iter()
1371            .map(|k| k.as_str().to_owned())
1372            .collect();
1373        listed.sort();
1374        assert_eq!(named, listed, "ActionKind::ALL is not the enum");
1375        for k in ActionKind::ALL {
1376            assert_eq!(
1377                serde_json::to_value(k).unwrap_or_default(),
1378                k.as_str(),
1379                "{k:?}"
1380            );
1381            if let Some(run) = k.example() {
1382                assert_eq!(run.kind(), *k);
1383                assert_eq!(identity(Some(&run), "").kind, *k);
1384            }
1385        }
1386        assert_eq!(
1387            Runnable::examples().len(),
1388            ActionKind::ALL.len() - 1,
1389            "every kind but `command` has a runnable"
1390        );
1391    }
1392
1393    #[test]
1394    fn identity_is_what_runs_never_how_it_is_worded() {
1395        let a = Action::new(
1396            "task",
1397            "accept T004",
1398            "tick it",
1399            "speccoder task accept T004",
1400        )
1401        .with_run(Runnable::Accept {
1402            task: "T004".to_owned(),
1403        });
1404        let mut b = a.clone();
1405        b.label = "x tick it anyway".to_owned();
1406        b.why = "x".to_owned();
1407        assert_eq!(a.id, b.id);
1408        assert_eq!(
1409            a.id,
1410            ActionId {
1411                kind: ActionKind::Accept,
1412                subject: Some("T004".to_owned())
1413            }
1414        );
1415        let id = |c: &str| identity(None, c).subject;
1416        assert_eq!(
1417            id("speccoder attest verify").as_deref(),
1418            Some("attest verify")
1419        );
1420        assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1421        assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1422        assert_eq!(
1423            id("speccoder spec specify \"…\"").as_deref(),
1424            Some("spec specify")
1425        );
1426        let review = |ids: &[&str], v: &str| {
1427            identity(
1428                Some(&Runnable::Review {
1429                    ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1430                    verdict: v.to_owned(),
1431                }),
1432                "",
1433            )
1434            .subject
1435        };
1436        assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1437        assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1438    }
1439
1440    #[test]
1441    fn origins_read_back_from_every_value_ever_recorded() {
1442        for (old, via, client) in [
1443            ("cli", Via::Terminal, None),
1444            ("tui", Via::Terminal, None),
1445            ("dashboard", Via::Terminal, None),
1446            ("terminal", Via::Terminal, None),
1447            ("web", Via::Web, None),
1448            ("editor Zed", Via::Acp, Some("Zed")),
1449        ] {
1450            assert_eq!(
1451                Via::read(old),
1452                Some((via, client.map(str::to_owned))),
1453                "{old}"
1454            );
1455        }
1456        let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1457        assert_eq!(parsed, Via::Terminal);
1458        assert_eq!(Via::Acp.as_str(), "acp");
1459        assert_eq!(Via::read("nobody"), None);
1460    }
1461
1462    fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1463        journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1464    }
1465
1466    #[test]
1467    fn the_journey_marks_what_is_done_and_what_is_now() {
1468        use StepState::{Done, Now, Todo};
1469        let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1470        let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1471        assert_eq!(
1472            states(JourneyFacts::default()),
1473            at([Now, Todo, Todo, Todo, Todo, Todo])
1474        );
1475        let planned = JourneyFacts {
1476            spec: true,
1477            plan: true,
1478            ..JourneyFacts::default()
1479        };
1480        assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1481        let written = JourneyFacts {
1482            tasks: true,
1483            uncommitted: true,
1484            total: 3,
1485            ..planned
1486        };
1487        assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1488        let running = JourneyFacts {
1489            uncommitted: false,
1490            finished: 1,
1491            ..written
1492        };
1493        assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1494        let finished = JourneyFacts {
1495            finished: 3,
1496            ..running
1497        };
1498        assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1499        // A task list with no task: the tasks are still to write, never run.
1500        let empty = JourneyFacts {
1501            tasks: true,
1502            ..planned
1503        };
1504        assert_eq!(states(empty), at([Done, Done, Now, Todo, Todo, Todo]));
1505    }
1506
1507    #[test]
1508    fn a_task_list_without_a_spec_skips_to_the_run() {
1509        use StepState::{Now, Skipped, Todo};
1510        let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1511        let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1512        let only_tasks = JourneyFacts {
1513            tasks: true,
1514            total: 4,
1515            finished: 1,
1516            ..JourneyFacts::default()
1517        };
1518        assert_eq!(
1519            states(only_tasks),
1520            at([
1521                Skipped,
1522                Skipped,
1523                StepState::Done,
1524                StepState::Done,
1525                Now,
1526                Todo
1527            ])
1528        );
1529        // A plan without a spec: the spec is skipped, the tasks are next.
1530        let planned = JourneyFacts {
1531            plan: true,
1532            ..JourneyFacts::default()
1533        };
1534        assert_eq!(
1535            states(planned),
1536            at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1537        );
1538    }
1539}