Skip to main content

speccoder_core/
actions.rs

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