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}
244
245/// What a comment action does.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
247#[serde(rename_all = "snake_case")]
248pub enum CommentOp {
249    /// A new draft.
250    Add,
251    /// Remove a draft.
252    Delete,
253    /// Keep an outdated draft.
254    Keep,
255}
256
257/// A prefix list in `speccoder.toml`.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
259#[serde(rename_all = "snake_case")]
260pub enum PolicyList {
261    /// `[commands] allow_prefix`.
262    CommandsPrefix,
263    /// `[sandbox] network_allow_prefix`.
264    NetworkPrefix,
265}
266
267impl PolicyList {
268    /// Its name as serialized.
269    #[must_use]
270    pub const fn as_str(self) -> &'static str {
271        match self {
272            Self::CommandsPrefix => "commands_prefix",
273            Self::NetworkPrefix => "network_prefix",
274        }
275    }
276
277    /// The configuration key it is (`commands.allow_prefix`).
278    #[must_use]
279    pub const fn key(self) -> &'static str {
280        match self {
281            Self::CommandsPrefix => "commands.allow_prefix",
282            Self::NetworkPrefix => "sandbox.network_allow_prefix",
283        }
284    }
285
286    /// The list for a configuration key.
287    #[must_use]
288    pub fn of_key(key: &str) -> Option<Self> {
289        match key {
290            "commands.allow_prefix" | "commands_prefix" => Some(Self::CommandsPrefix),
291            "sandbox.network_allow_prefix" | "network_prefix" => Some(Self::NetworkPrefix),
292            _ => None,
293        }
294    }
295}
296
297/// A batch decision.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
299#[serde(rename_all = "snake_case")]
300pub enum BatchVerdict {
301    /// Each listed call once.
302    Approve,
303    /// Each listed call, with one reason.
304    Reject,
305}
306
307/// What kind of action it is: one per [`Runnable`] kind (a run split by
308/// whether it names a task), plus [`ActionKind::Command`] for an action an
309/// interface does not run itself. Exhaustive at any one version; a new
310/// kind arrives with its `Runnable` variant (the *adding a kind* contract
311/// of `speccoder_runtime::actions::perform`).
312#[derive(
313    Debug,
314    Clone,
315    Copy,
316    PartialEq,
317    Eq,
318    Hash,
319    PartialOrd,
320    Ord,
321    Serialize,
322    Deserialize,
323    schemars::JsonSchema,
324)]
325#[serde(rename_all = "snake_case")]
326pub enum ActionKind {
327    /// [`Runnable::Skill`].
328    Skill,
329    /// [`Runnable::Run`] with a task.
330    RunTask,
331    /// [`Runnable::Run`] without one: the feature.
332    RunFeature,
333    /// [`Runnable::Converge`].
334    Converge,
335    /// [`Runnable::Commit`].
336    Commit,
337    /// [`Runnable::Approve`].
338    Approve,
339    /// [`Runnable::Reject`].
340    Reject,
341    /// [`Runnable::Accept`].
342    Accept,
343    /// [`Runnable::Skip`].
344    Skip,
345    /// [`Runnable::Nudge`].
346    Nudge,
347    /// [`Runnable::Rule`].
348    Rule,
349    /// [`Runnable::Review`].
350    Review,
351    /// [`Runnable::IssueComment`].
352    IssueComment,
353    /// [`Runnable::QuickChange`].
354    QuickChange,
355    /// [`Runnable::KeepSetAside`].
356    KeepSetAside,
357    /// [`Runnable::DropSetAside`].
358    DropSetAside,
359    /// [`Runnable::FixSetup`].
360    FixSetup,
361    /// [`Runnable::Feature`].
362    Feature,
363    /// [`Runnable::PullRequest`].
364    PullRequest,
365    /// [`Runnable::BugFix`].
366    BugFix,
367    /// [`Runnable::BugConfirm`].
368    BugConfirm,
369    /// [`Runnable::BugRun`].
370    BugRun,
371    /// [`Runnable::BugKeep`].
372    BugKeep,
373    /// [`Runnable::AllowAlways`].
374    AllowAlways,
375    /// [`Runnable::WidenPolicy`].
376    WidenPolicy,
377    /// [`Runnable::DecideBatch`].
378    DecideBatch,
379    /// [`Runnable::OracleTests`].
380    OracleTests,
381    /// [`Runnable::ApproveAndRun`].
382    ApproveAndRun,
383    /// [`Runnable::Comment`].
384    Comment,
385    /// [`Runnable::CommentsSend`].
386    CommentsSend,
387    /// [`Runnable::RevisionUndo`].
388    RevisionUndo,
389    /// [`Runnable::CodeNudge`].
390    CodeNudge,
391    /// No runnable: the person runs the command.
392    Command,
393}
394
395impl ActionKind {
396    /// Every kind, in declaration order (a test holds it to the enum).
397    pub const ALL: &'static [Self] = &[
398        Self::Skill,
399        Self::RunTask,
400        Self::RunFeature,
401        Self::Converge,
402        Self::Commit,
403        Self::Approve,
404        Self::Reject,
405        Self::Accept,
406        Self::Skip,
407        Self::Nudge,
408        Self::Rule,
409        Self::Review,
410        Self::IssueComment,
411        Self::QuickChange,
412        Self::KeepSetAside,
413        Self::DropSetAside,
414        Self::FixSetup,
415        Self::Feature,
416        Self::PullRequest,
417        Self::BugFix,
418        Self::BugConfirm,
419        Self::BugRun,
420        Self::BugKeep,
421        Self::AllowAlways,
422        Self::WidenPolicy,
423        Self::DecideBatch,
424        Self::OracleTests,
425        Self::ApproveAndRun,
426        Self::Comment,
427        Self::CommentsSend,
428        Self::RevisionUndo,
429        Self::CodeNudge,
430        Self::Command,
431    ];
432
433    /// Its name as serialized (`run_task`).
434    #[must_use]
435    pub const fn as_str(self) -> &'static str {
436        match self {
437            Self::Skill => "skill",
438            Self::RunTask => "run_task",
439            Self::RunFeature => "run_feature",
440            Self::Converge => "converge",
441            Self::Commit => "commit",
442            Self::Approve => "approve",
443            Self::Reject => "reject",
444            Self::Accept => "accept",
445            Self::Skip => "skip",
446            Self::Nudge => "nudge",
447            Self::Rule => "rule",
448            Self::Review => "review",
449            Self::IssueComment => "issue_comment",
450            Self::QuickChange => "quick_change",
451            Self::KeepSetAside => "keep_set_aside",
452            Self::DropSetAside => "drop_set_aside",
453            Self::FixSetup => "fix_setup",
454            Self::Feature => "feature",
455            Self::PullRequest => "pull_request",
456            Self::BugFix => "bug_fix",
457            Self::BugConfirm => "bug_confirm",
458            Self::BugRun => "bug_run",
459            Self::BugKeep => "bug_keep",
460            Self::AllowAlways => "allow_always",
461            Self::WidenPolicy => "widen_policy",
462            Self::DecideBatch => "decide_batch",
463            Self::OracleTests => "oracle_tests",
464            Self::ApproveAndRun => "approve_and_run",
465            Self::Comment => "comment",
466            Self::CommentsSend => "comments_send",
467            Self::RevisionUndo => "revision_undo",
468            Self::CodeNudge => "code_nudge",
469            Self::Command => "command",
470        }
471    }
472
473    /// One runnable of this kind (`None` for [`ActionKind::Command`]): what
474    /// tests and documentation enumerate the kinds by.
475    #[must_use]
476    pub fn example(self) -> Option<Runnable> {
477        let task = || "T001".to_owned();
478        Some(match self {
479            Self::Skill => Runnable::Skill {
480                name: "plan".to_owned(),
481                arguments: String::new(),
482            },
483            Self::RunTask => Runnable::Run { task: Some(task()) },
484            Self::RunFeature => Runnable::Run { task: None },
485            Self::Converge => Runnable::Converge,
486            Self::Commit => Runnable::Commit {
487                paths: vec!["plan.md".to_owned()],
488            },
489            Self::Approve => Runnable::Approve {
490                task: task(),
491                call: "0f3c".to_owned(),
492                digest: "d1".to_owned(),
493            },
494            Self::Reject => Runnable::Reject {
495                task: task(),
496                call: "0f3c".to_owned(),
497                digest: "d1".to_owned(),
498            },
499            Self::Accept => Runnable::Accept { task: task() },
500            Self::Skip => Runnable::Skip { task: task() },
501            Self::Nudge => Runnable::Nudge { task: task() },
502            Self::Rule => Runnable::Rule { task: task() },
503            Self::Review => Runnable::Review {
504                ids: vec!["FR-001".to_owned()],
505                verdict: "approve".to_owned(),
506            },
507            Self::IssueComment => Runnable::IssueComment {
508                issue: 42,
509                assign: false,
510            },
511            Self::QuickChange => Runnable::QuickChange {
512                request: "a change".to_owned(),
513                checks: Vec::new(),
514                run: false,
515            },
516            Self::KeepSetAside => Runnable::KeepSetAside { task: task() },
517            Self::DropSetAside => Runnable::DropSetAside { task: task() },
518            Self::FixSetup => Runnable::FixSetup,
519            Self::Feature => Runnable::Feature,
520            Self::PullRequest => Runnable::PullRequest,
521            Self::BugFix => Runnable::BugFix {
522                symptom: "saving an empty title crashes".to_owned(),
523                expected: vec!["an empty title is refused".to_owned()],
524                unchanged: Vec::new(),
525                issue: None,
526                tests: Vec::new(),
527                candidate: None,
528                slug: None,
529                run: false,
530            },
531            Self::BugConfirm => Runnable::BugConfirm {
532                task: "007-bugfixes/T001".to_owned(),
533                digest: "sha256:0".to_owned(),
534                reject: false,
535            },
536            Self::BugRun => Runnable::BugRun {
537                slug: None,
538                task: Some("007-bugfixes/T001".to_owned()),
539            },
540            Self::BugKeep => Runnable::BugKeep {
541                task: "007-bugfixes/T001".to_owned(),
542            },
543            Self::AllowAlways => Runnable::AllowAlways {
544                task: task(),
545                call: "0f3c".to_owned(),
546            },
547            Self::WidenPolicy => Runnable::WidenPolicy {
548                key: PolicyList::CommandsPrefix,
549                prefix: vec!["npm".to_owned(), "run".to_owned()],
550                digest: "d1".to_owned(),
551            },
552            Self::DecideBatch => Runnable::DecideBatch {
553                run: "run-1-0a0b0c0d".to_owned(),
554                verdict: BatchVerdict::Approve,
555                calls: vec!["0f3c".to_owned()],
556                digest: "d1".to_owned(),
557                resume_asking: false,
558            },
559            Self::OracleTests => Runnable::OracleTests {
560                req: "US1".to_owned(),
561                op: crate::oracle::OracleOp::Write,
562                path: None,
563                scenario: None,
564                reading: None,
565            },
566            Self::ApproveAndRun => Runnable::ApproveAndRun {
567                mode: crate::oracle::RunMode::Ask,
568                grants: Vec::new(),
569                sheet: None,
570            },
571            Self::Comment => Runnable::Comment {
572                op: CommentOp::Delete,
573                anchor: None,
574                id: Some("c1".to_owned()),
575            },
576            Self::CommentsSend => Runnable::CommentsSend { ids: Vec::new() },
577            Self::RevisionUndo => Runnable::RevisionUndo { turn: None },
578            Self::CodeNudge => Runnable::CodeNudge {
579                task: task(),
580                comments: vec![crate::oracle::CodeComment {
581                    path: "src/lib.rs".to_owned(),
582                    line: 1,
583                    quote: "pub fn ok() {}".to_owned(),
584                    text: "name it".to_owned(),
585                }],
586            },
587            Self::Command => return None,
588        })
589    }
590}
591
592impl Runnable {
593    /// One runnable of every kind ([`ActionKind::example`]).
594    #[must_use]
595    pub fn examples() -> Vec<Self> {
596        ActionKind::ALL.iter().filter_map(|k| k.example()).collect()
597    }
598
599    /// Its kind.
600    #[must_use]
601    pub const fn kind(&self) -> ActionKind {
602        match self {
603            Self::Skill { .. } => ActionKind::Skill,
604            Self::Run { task: Some(_) } => ActionKind::RunTask,
605            Self::Run { task: None } => ActionKind::RunFeature,
606            Self::Converge => ActionKind::Converge,
607            Self::Commit { .. } => ActionKind::Commit,
608            Self::Approve { .. } => ActionKind::Approve,
609            Self::Reject { .. } => ActionKind::Reject,
610            Self::Accept { .. } => ActionKind::Accept,
611            Self::Skip { .. } => ActionKind::Skip,
612            Self::Nudge { .. } => ActionKind::Nudge,
613            Self::Rule { .. } => ActionKind::Rule,
614            Self::Review { .. } => ActionKind::Review,
615            Self::IssueComment { .. } => ActionKind::IssueComment,
616            Self::QuickChange { .. } => ActionKind::QuickChange,
617            Self::KeepSetAside { .. } => ActionKind::KeepSetAside,
618            Self::DropSetAside { .. } => ActionKind::DropSetAside,
619            Self::FixSetup => ActionKind::FixSetup,
620            Self::Feature => ActionKind::Feature,
621            Self::PullRequest => ActionKind::PullRequest,
622            Self::BugFix { .. } => ActionKind::BugFix,
623            Self::BugConfirm { .. } => ActionKind::BugConfirm,
624            Self::BugRun { .. } => ActionKind::BugRun,
625            Self::BugKeep { .. } => ActionKind::BugKeep,
626            Self::AllowAlways { .. } => ActionKind::AllowAlways,
627            Self::WidenPolicy { .. } => ActionKind::WidenPolicy,
628            Self::DecideBatch { .. } => ActionKind::DecideBatch,
629            Self::OracleTests { .. } => ActionKind::OracleTests,
630            Self::ApproveAndRun { .. } => ActionKind::ApproveAndRun,
631            Self::Comment { .. } => ActionKind::Comment,
632            Self::CommentsSend { .. } => ActionKind::CommentsSend,
633            Self::RevisionUndo { .. } => ActionKind::RevisionUndo,
634            Self::CodeNudge { .. } => ActionKind::CodeNudge,
635        }
636    }
637}
638
639/// What an action is, independent of its wording: its kind and what it
640/// is about (the task, the skill, the issue). An interface finds an action
641/// by this, never by its label.
642#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
643pub struct ActionId {
644    /// Its kind.
645    pub kind: ActionKind,
646    /// What it is about: a task's id, a skill's name, an issue's number,
647    /// a review's `verdict` (`verdict:first id`), a command's words.
648    pub subject: Option<String>,
649}
650
651/// The identity of the action that runs `run` (or, without one, stands
652/// for `command`): derived, so it cannot disagree with what runs.
653#[must_use]
654pub fn identity(run: Option<&Runnable>, command: &str) -> ActionId {
655    let Some(run) = run else {
656        // The command's words: `speccoder attest verify` → `attest verify`.
657        let words: Vec<&str> = command
658            .split_whitespace()
659            .skip_while(|w| *w == "speccoder")
660            .take_while(|w| {
661                w.chars().next().is_some_and(|c| c.is_ascii_lowercase())
662                    && w.chars()
663                        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
664            })
665            .collect();
666        return ActionId {
667            kind: ActionKind::Command,
668            subject: (!words.is_empty()).then(|| words.join(" ")),
669        };
670    };
671    let subject = match run {
672        Runnable::Skill { name, .. } => Some(name.clone()),
673        Runnable::Run { task } => task.clone(),
674        Runnable::Approve { task, .. }
675        | Runnable::Reject { task, .. }
676        | Runnable::Accept { task }
677        | Runnable::Skip { task }
678        | Runnable::Nudge { task }
679        | Runnable::Rule { task }
680        | Runnable::KeepSetAside { task }
681        | Runnable::DropSetAside { task }
682        | Runnable::AllowAlways { task, .. }
683        | Runnable::CodeNudge { task, .. } => Some(task.clone()),
684        Runnable::OracleTests { req, .. } => Some(req.clone()),
685        Runnable::ApproveAndRun { mode, .. } => Some(mode.as_str().to_owned()),
686        Runnable::Comment { id, .. } => id.clone(),
687        Runnable::RevisionUndo { turn } => turn.clone(),
688        Runnable::WidenPolicy { key, .. } => Some(key.as_str().to_owned()),
689        Runnable::DecideBatch { run, .. } => Some(run.clone()),
690        Runnable::IssueComment { issue, .. } => Some(issue.to_string()),
691        Runnable::BugConfirm { task, .. } | Runnable::BugKeep { task } => Some(task.clone()),
692        Runnable::BugRun { slug, task } => task.clone().or_else(|| slug.clone()),
693        Runnable::BugFix { slug, issue, .. } => {
694            slug.clone().or_else(|| issue.map(|n| format!("#{n}")))
695        }
696        Runnable::Review { ids, verdict } => Some(match ids.first() {
697            Some(id) => format!("{verdict}:{id}"),
698            None => verdict.clone(),
699        }),
700        Runnable::Converge
701        | Runnable::Commit { .. }
702        | Runnable::QuickChange { .. }
703        | Runnable::FixSetup
704        | Runnable::Feature
705        | Runnable::PullRequest
706        | Runnable::CommentsSend { .. } => None,
707    };
708    ActionId {
709        kind: run.kind(),
710        subject,
711    }
712}
713
714/// The part a planning skill plays in its format's planning, from the
715/// format's adapter: what an interface finds "the create step" or "the
716/// analysis step" by, without naming a format's skill.
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
718#[serde(rename_all = "snake_case")]
719pub enum SkillRole {
720    /// It creates a unit (Spec Kit's `specify`).
721    Create,
722    /// It leads toward the task list (`plan`, `tasks`).
723    TowardTasks,
724    /// It revises a written spec (`clarify`).
725    Revise,
726    /// It checks the planning files for consistency (`analyze`).
727    Check,
728    /// Anything else.
729    Other,
730}
731
732/// The interface a person acted through, as a write records it (`via`):
733/// one vocabulary for every record. Records written before it named
734/// `cli`, `tui` or `dashboard` (read as [`Via::Terminal`]) and
735/// `editor <name>` (read as [`Via::Acp`], the name a separate client).
736#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
737#[serde(rename_all = "snake_case")]
738pub enum Via {
739    /// A terminal: the line CLI, its prompts, the dashboard.
740    #[serde(alias = "cli", alias = "tui", alias = "dashboard")]
741    Terminal,
742    /// The web app, in a browser tab or the desktop window.
743    Web,
744    /// The desktop window, once it can tell itself from a tab.
745    Window,
746    /// An editor over the Agent Client Protocol.
747    Acp,
748    /// A phone.
749    Phone,
750    /// Continuous integration.
751    Ci,
752    /// A client over the Model Context Protocol.
753    Mcp,
754}
755
756impl Via {
757    /// Its recorded value (`terminal`).
758    #[must_use]
759    pub const fn as_str(self) -> &'static str {
760        match self {
761            Self::Terminal => "terminal",
762            Self::Web => "web",
763            Self::Window => "window",
764            Self::Acp => "acp",
765            Self::Phone => "phone",
766            Self::Ci => "ci",
767            Self::Mcp => "mcp",
768        }
769    }
770
771    /// A recorded value, read back with the client it names (`editor Zed`
772    /// → `acp`, `Zed`); `None` for a value no interface ever recorded.
773    #[must_use]
774    pub fn read(recorded: &str) -> Option<(Self, Option<String>)> {
775        if let Some(client) = recorded.strip_prefix("editor ") {
776            return Some((Self::Acp, Some(client.to_owned())));
777        }
778        let via = match recorded {
779            "terminal" | "cli" | "tui" | "dashboard" => Self::Terminal,
780            "web" => Self::Web,
781            "window" => Self::Window,
782            "acp" | "editor" => Self::Acp,
783            "phone" => Self::Phone,
784            "ci" => Self::Ci,
785            "mcp" => Self::Mcp,
786            _ => return None,
787        };
788        Some((via, None))
789    }
790}
791
792impl std::fmt::Display for Via {
793    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        f.write_str(self.as_str())
795    }
796}
797
798/// One thing a person can do now.
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
800pub struct Action {
801    /// `next`, `plan`, `work`, `task`, `feature` or `review`.
802    pub group: String,
803    /// What it does, in a few words.
804    pub label: String,
805    /// Why, or what follows (shown beside it).
806    pub why: String,
807    /// The equivalent command; `"…"` marks the input.
808    pub command: String,
809    /// How an interface runs it; `None`: run the command yourself (it leaves
810    /// the machine, or it is not something an interface hosts).
811    pub run: Option<Runnable>,
812    /// Why it cannot run now; `None`: it can.
813    pub blocked: Option<String>,
814    /// What to ask for before it runs (`Describe the feature`).
815    pub input: Option<String>,
816    /// It leaves the machine (a push, a pull request) or cannot be undone
817    /// (a discard): what an interface says in a confirmation before it runs it.
818    pub confirm: Option<String>,
819    /// What it is, independent of its wording ([`identity`]).
820    pub id: ActionId,
821    /// A planning skill's part in its format's planning; `None` for
822    /// everything else.
823    pub role: Option<SkillRole>,
824}
825
826impl Action {
827    /// An action that stands for `command`, which the person runs.
828    #[must_use]
829    pub fn new(
830        group: &str,
831        label: impl Into<String>,
832        why: impl Into<String>,
833        command: impl Into<String>,
834    ) -> Self {
835        let command = command.into();
836        Self {
837            group: group.to_owned(),
838            label: label.into(),
839            why: why.into(),
840            id: identity(None, &command),
841            command,
842            run: None,
843            blocked: None,
844            input: None,
845            confirm: None,
846            role: None,
847        }
848    }
849
850    /// Run by an interface as `run`; its identity follows.
851    #[must_use]
852    pub fn with_run(mut self, run: Runnable) -> Self {
853        self.id = identity(Some(&run), &self.command);
854        self.run = Some(run);
855        self
856    }
857
858    /// A planning skill playing `role`.
859    #[must_use]
860    pub const fn with_role(mut self, role: SkillRole) -> Self {
861        self.role = Some(role);
862        self
863    }
864
865    /// It can be run from an interface, now.
866    #[must_use]
867    pub const fn runnable(&self) -> bool {
868        self.run.is_some() && self.blocked.is_none()
869    }
870}
871
872/// The primary action: the first runnable one of the `next` group.
873#[must_use]
874pub fn primary(actions: &[Action]) -> Option<&Action> {
875    actions.iter().find(|a| a.group == "next" && a.runnable())
876}
877
878/// Where a step of the journey is.
879#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
880#[serde(rename_all = "snake_case")]
881pub enum StepState {
882    /// Done.
883    Done,
884    /// The one to take now.
885    Now,
886    /// Later.
887    Todo,
888    /// Passed without its file: a task list written without a spec or a
889    /// plan. Not a step left to take.
890    Skipped,
891}
892
893/// One step of a feature's journey: `spec`, `plan`, `tasks`, `commit`,
894/// `run`, `review`.
895#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
896pub struct JourneyStep {
897    /// Its name.
898    pub name: String,
899    /// Where it is.
900    pub state: StepState,
901}
902
903/// Facts the journey is derived from.
904#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
905pub struct JourneyFacts {
906    /// `spec.md` exists.
907    pub spec: bool,
908    /// `plan.md` exists.
909    pub plan: bool,
910    /// The task list exists.
911    pub tasks: bool,
912    /// Planning files are not committed.
913    pub uncommitted: bool,
914    /// Tasks in the list.
915    pub total: usize,
916    /// Of them ticked or skipped.
917    pub finished: usize,
918}
919
920/// The journey from a spec to a reviewed branch, and where the feature is
921/// on it: everything before the first open step is done (or skipped, when a
922/// later planning file exists without it), that one is now.
923#[must_use]
924pub fn journey(f: JourneyFacts) -> Vec<JourneyStep> {
925    let skipped = [!f.spec && (f.plan || f.tasks), !f.plan && f.tasks];
926    let done = [
927        ("spec", f.spec),
928        ("plan", f.plan),
929        ("tasks", f.tasks),
930        ("commit", f.tasks && !f.uncommitted),
931        ("run", f.total > 0 && f.finished == f.total),
932        ("review", false),
933    ];
934    let passed = |i: usize| skipped.get(i).copied().unwrap_or(false);
935    let now = done
936        .iter()
937        .enumerate()
938        .position(|(i, (_, d))| !d && !passed(i))
939        .unwrap_or(done.len());
940    done.iter()
941        .enumerate()
942        .map(|(i, (name, d))| JourneyStep {
943            name: (*name).to_owned(),
944            state: if *d && i < now {
945                StepState::Done
946            } else if passed(i) && i < now {
947                StepState::Skipped
948            } else if i == now {
949                StepState::Now
950            } else {
951                StepState::Todo
952            },
953        })
954        .collect()
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960
961    #[test]
962    fn every_kind_is_listed_once_and_every_runnable_has_its_own() {
963        // The schema lists every variant the compiler knows.
964        let schema = serde_json::to_value(schemars::schema_for!(ActionKind))
965            .unwrap_or_else(|e| panic!("{e}"));
966        let mut named: Vec<String> = schema["oneOf"]
967            .as_array()
968            .into_iter()
969            .flatten()
970            .filter_map(|v| v["const"].as_str().map(str::to_owned))
971            .collect();
972        named.sort();
973        let mut listed: Vec<String> = ActionKind::ALL
974            .iter()
975            .map(|k| k.as_str().to_owned())
976            .collect();
977        listed.sort();
978        assert_eq!(named, listed, "ActionKind::ALL is not the enum");
979        for k in ActionKind::ALL {
980            assert_eq!(
981                serde_json::to_value(k).unwrap_or_default(),
982                k.as_str(),
983                "{k:?}"
984            );
985            if let Some(run) = k.example() {
986                assert_eq!(run.kind(), *k);
987                assert_eq!(identity(Some(&run), "").kind, *k);
988            }
989        }
990        assert_eq!(
991            Runnable::examples().len(),
992            ActionKind::ALL.len() - 1,
993            "every kind but `command` has a runnable"
994        );
995    }
996
997    #[test]
998    fn identity_is_what_runs_never_how_it_is_worded() {
999        let a = Action::new(
1000            "task",
1001            "accept T004",
1002            "tick it",
1003            "speccoder task accept T004",
1004        )
1005        .with_run(Runnable::Accept {
1006            task: "T004".to_owned(),
1007        });
1008        let mut b = a.clone();
1009        b.label = "x tick it anyway".to_owned();
1010        b.why = "x".to_owned();
1011        assert_eq!(a.id, b.id);
1012        assert_eq!(
1013            a.id,
1014            ActionId {
1015                kind: ActionKind::Accept,
1016                subject: Some("T004".to_owned())
1017            }
1018        );
1019        let id = |c: &str| identity(None, c).subject;
1020        assert_eq!(
1021            id("speccoder attest verify").as_deref(),
1022            Some("attest verify")
1023        );
1024        assert_eq!(id("speccoder doctor").as_deref(), Some("doctor"));
1025        assert_eq!(id("speccoder pr --yes").as_deref(), Some("pr"));
1026        assert_eq!(
1027            id("speccoder spec specify \"…\"").as_deref(),
1028            Some("spec specify")
1029        );
1030        let review = |ids: &[&str], v: &str| {
1031            identity(
1032                Some(&Runnable::Review {
1033                    ids: ids.iter().map(|s| (*s).to_owned()).collect(),
1034                    verdict: v.to_owned(),
1035                }),
1036                "",
1037            )
1038            .subject
1039        };
1040        assert_eq!(review(&[], "approve").as_deref(), Some("approve"));
1041        assert_eq!(review(&["Q1"], "answer").as_deref(), Some("answer:Q1"));
1042    }
1043
1044    #[test]
1045    fn origins_read_back_from_every_value_ever_recorded() {
1046        for (old, via, client) in [
1047            ("cli", Via::Terminal, None),
1048            ("tui", Via::Terminal, None),
1049            ("dashboard", Via::Terminal, None),
1050            ("terminal", Via::Terminal, None),
1051            ("web", Via::Web, None),
1052            ("editor Zed", Via::Acp, Some("Zed")),
1053        ] {
1054            assert_eq!(
1055                Via::read(old),
1056                Some((via, client.map(str::to_owned))),
1057                "{old}"
1058            );
1059        }
1060        let parsed: Via = serde_json::from_str("\"dashboard\"").unwrap_or(Via::Web);
1061        assert_eq!(parsed, Via::Terminal);
1062        assert_eq!(Via::Acp.as_str(), "acp");
1063        assert_eq!(Via::read("nobody"), None);
1064    }
1065
1066    fn states(f: JourneyFacts) -> Vec<(String, StepState)> {
1067        journey(f).into_iter().map(|s| (s.name, s.state)).collect()
1068    }
1069
1070    #[test]
1071    fn the_journey_marks_what_is_done_and_what_is_now() {
1072        use StepState::{Done, Now, Todo};
1073        let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1074        let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1075        assert_eq!(
1076            states(JourneyFacts::default()),
1077            at([Now, Todo, Todo, Todo, Todo, Todo])
1078        );
1079        let planned = JourneyFacts {
1080            spec: true,
1081            plan: true,
1082            ..JourneyFacts::default()
1083        };
1084        assert_eq!(states(planned), at([Done, Done, Now, Todo, Todo, Todo]));
1085        let written = JourneyFacts {
1086            tasks: true,
1087            uncommitted: true,
1088            total: 3,
1089            ..planned
1090        };
1091        assert_eq!(states(written), at([Done, Done, Done, Now, Todo, Todo]));
1092        let running = JourneyFacts {
1093            uncommitted: false,
1094            finished: 1,
1095            ..written
1096        };
1097        assert_eq!(states(running), at([Done, Done, Done, Done, Now, Todo]));
1098        let finished = JourneyFacts {
1099            finished: 3,
1100            ..running
1101        };
1102        assert_eq!(states(finished), at([Done, Done, Done, Done, Done, Now]));
1103    }
1104
1105    #[test]
1106    fn a_task_list_without_a_spec_skips_to_the_run() {
1107        use StepState::{Now, Skipped, Todo};
1108        let names = ["spec", "plan", "tasks", "commit", "run", "review"].map(String::from);
1109        let at = |v: [StepState; 6]| names.iter().cloned().zip(v).collect::<Vec<_>>();
1110        let only_tasks = JourneyFacts {
1111            tasks: true,
1112            total: 4,
1113            finished: 1,
1114            ..JourneyFacts::default()
1115        };
1116        assert_eq!(
1117            states(only_tasks),
1118            at([
1119                Skipped,
1120                Skipped,
1121                StepState::Done,
1122                StepState::Done,
1123                Now,
1124                Todo
1125            ])
1126        );
1127        // A plan without a spec: the spec is skipped, the tasks are next.
1128        let planned = JourneyFacts {
1129            plan: true,
1130            ..JourneyFacts::default()
1131        };
1132        assert_eq!(
1133            states(planned),
1134            at([Skipped, StepState::Done, Now, Todo, Todo, Todo])
1135        );
1136    }
1137}