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