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