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