Skip to main content

majordomus_cli/devtask/
mod.rs

1//! The executable development task: one issue or one milestone read as work to be done,
2//! with the origin of every value on the value.
3//!
4//! # What this is, and what it is not
5//!
6//! It is **not** a second project model. The canonical records under
7//! `.ai/repo/project/` are the only declaration, [`crate::plan`] is the only derivation of
8//! status, waves, dependents and findings out of them, `worktree::trace` is the
9//! only derivation of the branches and commits that realised an issue, and
10//! `scripts/github-sync` is the only adapter that projects either onto GitHub. This module
11//! composes those three and adds nothing to them: every status it reports is the status
12//! `plan.issues` reports, and `task::tests` holds that equality as an assertion.
13//!
14//! What it adds is the thing none of them could add on its own:
15//!
16//! 1. **One answer.** A worker asking "what is this issue, and can I start it?" had to call
17//!    `plan.issues`, `trace.issue` and a shell adapter, and join three shapes by hand. The
18//!    join is a derivation like any other, so it belongs in the executable.
19//! 2. **Provenance on every field.** The plan flattens a missing key to the empty string,
20//!    because that is what the awk it is held to byte-equality with does. That is right for
21//!    the plan and wrong for a reader deciding whether to trust a value: `objective: ""` and
22//!    a record with no `objective` at all are the same string and different facts. Every
23//!    field here carries a [`FieldProvenance`] — [`Explicit`], [`Derived`], [`Inferred`] or
24//!    [`Unknown`] — and the constructors are the only way to build one, so a field cannot
25//!    claim to be authored unless the record's own metadata carried the key.
26//! 3. **Readiness as a derived vocabulary.** [`readiness::TaskReadiness`] is a projection of the
27//!    canonical status, never a replacement for it; it splits exactly the three distinctions
28//!    the canonical vocabulary conflates and invents nothing else. See that module.
29//!
30//! [`Explicit`]: FieldProvenance::Explicit
31//! [`Derived`]: FieldProvenance::Derived
32//! [`Inferred`]: FieldProvenance::Inferred
33//! [`Unknown`]: FieldProvenance::Unknown
34//!
35//! # The four kinds of fact, kept apart by the type
36//!
37//! A development task mixes facts that answer to different owners, and conflating them is
38//! how a projection ends up overwriting what a person wrote. So they are four groups of one
39//! structure rather than one flat bag of keys:
40//!
41//! | group | owner | may this executable write it |
42//! |---|---|---|
43//! | [`task::DevTaskDeclaration`] | the canonical record, authored by a person | no; `majordomus plan record` writes |
44//! | [`task::DevTaskPosition`] | derived from the plan graph, stored nowhere | nothing to write |
45//! | [`task::DevTaskExecution`] | local execution state: git refs, commits, sessions | nothing to write |
46//! | [`task::DevTaskSynchronisation`] | the external projection on GitHub | **never**; the adapter owns it |
47//!
48//! Nothing here writes anything at all, which is what makes the last row safe: a
49//! user-authored GitHub value cannot be silently overwritten by a reader.
50
51pub mod graph;
52pub mod readiness;
53pub mod task;
54
55use schemars::JsonSchema;
56use serde::{Deserialize, Serialize};
57
58pub use graph::{
59    CriticalBlocker, MilestoneCounts, MilestoneEdge, MilestoneGraph, MilestoneNode, ParallelSet,
60    ScopeConflict,
61};
62pub use readiness::{Blocker, BlockerKind, TaskReadiness};
63pub use task::{
64    DevTask, DevTaskDeclaration, DevTaskDiagnostic, DevTaskExecution, DevTaskPosition,
65    DevTaskSynchronisation, ReadinessVerdict,
66};
67
68/// Where a value came from, and therefore how much weight a reader may put on it.
69///
70/// The four words are not degrees of confidence, they are four different relations to the
71/// repository, and the distinction that matters most is the first from the last two: a
72/// person wrote it, or a machine worked it out.
73///
74/// ```
75/// use majordomus_cli::devtask::{AttestedText, FieldProvenance};
76///
77/// // a value the record itself carried
78/// let title = AttestedText::explicit("Ship it", ".ai/repo/project/issues/I0001.yaml#title");
79/// assert_eq!(title.provenance, FieldProvenance::Explicit);
80///
81/// // a key the record does not have is unknown, and unknown carries no value
82/// let missing = AttestedText::unknown(
83///     ".ai/repo/project/issues/I0001.yaml#objective",
84///     "the record declares no `objective`",
85/// );
86/// assert_eq!(missing.provenance, FieldProvenance::Unknown);
87/// assert!(missing.value.is_none());
88/// ```
89#[derive(
90    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
91)]
92#[serde(rename_all = "snake_case")]
93pub enum FieldProvenance {
94    /// A person authored it, in the canonical record, under this key. The strongest thing
95    /// that can be said about a value, and the only one that survives a rewrite of every
96    /// derivation in this repository.
97    Explicit,
98    /// A machine worked it out from records or from git, by a rule that cannot be wrong
99    /// about a repository it can read: an issue's dependents are the issues that name it.
100    /// Re-derived on every call and stored nowhere.
101    Derived,
102    /// A machine worked it out by a rule that *can* be wrong, because the relation it reads
103    /// was never declared: a session belongs to an issue because its branch name contains
104    /// the issue's id. Useful, and never to be mistaken for a declaration.
105    Inferred,
106    /// Not available. Either the record does not carry the key, or the fact lives outside
107    /// what this process may read — a live GitHub state, for one — and the field says which
108    /// in its `reason`. An unknown field never carries a value.
109    Unknown,
110}
111
112impl FieldProvenance {
113    /// The word a projection prints.
114    ///
115    /// ```
116    /// use majordomus_cli::devtask::FieldProvenance;
117    /// assert_eq!(FieldProvenance::Explicit.as_str(), "explicit");
118    /// assert_eq!(FieldProvenance::Unknown.as_str(), "unknown");
119    /// ```
120    pub fn as_str(self) -> &'static str {
121        match self {
122            FieldProvenance::Explicit => "explicit",
123            FieldProvenance::Derived => "derived",
124            FieldProvenance::Inferred => "inferred",
125            FieldProvenance::Unknown => "unknown",
126        }
127    }
128
129    /// Did a person write this, in the canonical record?
130    ///
131    /// ```
132    /// use majordomus_cli::devtask::FieldProvenance;
133    /// assert!(FieldProvenance::Explicit.is_authored());
134    /// assert!(!FieldProvenance::Derived.is_authored());
135    /// ```
136    pub fn is_authored(self) -> bool {
137        matches!(self, FieldProvenance::Explicit)
138    }
139}
140
141/// One scalar with its provenance: the unit every field of a development task is made of.
142///
143/// The invariant the constructors enforce, and [`AttestedText::is_consistent`] states, is
144/// that a value is present exactly when the provenance is not [`FieldProvenance::Unknown`].
145/// Without it the shape can express "unknown, and here is the value", which is the defect
146/// this whole module exists to prevent.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
148pub struct AttestedText {
149    /// The value, absent exactly when the provenance is `unknown`.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub value: Option<String>,
152    /// Where it came from.
153    pub provenance: FieldProvenance,
154    /// The one place a reader goes to check it: a repository-relative path with the key it
155    /// was read from, a capability id, or a command. Never a machine path.
156    pub source: String,
157    /// Why the provenance is what it is, when that is worth a sentence: always for
158    /// `unknown` and for `inferred`, and omitted otherwise.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub reason: Option<String>,
161}
162
163impl AttestedText {
164    /// A value the canonical record carried under this key.
165    pub fn explicit(value: impl Into<String>, source: impl Into<String>) -> Self {
166        Self {
167            value: Some(value.into()),
168            provenance: FieldProvenance::Explicit,
169            source: source.into(),
170            reason: None,
171        }
172    }
173
174    /// A value worked out by a rule that cannot be wrong about a readable repository.
175    pub fn derived(value: impl Into<String>, source: impl Into<String>) -> Self {
176        Self {
177            value: Some(value.into()),
178            provenance: FieldProvenance::Derived,
179            source: source.into(),
180            reason: None,
181        }
182    }
183
184    /// A value worked out by a rule that can be wrong, with the rule stated.
185    pub fn inferred(
186        value: impl Into<String>,
187        source: impl Into<String>,
188        reason: impl Into<String>,
189    ) -> Self {
190        Self {
191            value: Some(value.into()),
192            provenance: FieldProvenance::Inferred,
193            source: source.into(),
194            reason: Some(reason.into()),
195        }
196    }
197
198    /// No value, and why there is none.
199    pub fn unknown(source: impl Into<String>, reason: impl Into<String>) -> Self {
200        Self {
201            value: None,
202            provenance: FieldProvenance::Unknown,
203            source: source.into(),
204            reason: Some(reason.into()),
205        }
206    }
207
208    /// The value, or the empty string. For a renderer; never for a decision.
209    pub fn text(&self) -> &str {
210        self.value.as_deref().unwrap_or_default()
211    }
212
213    /// Is the invariant held: a value present exactly when the provenance is not unknown?
214    ///
215    /// ```
216    /// use majordomus_cli::devtask::AttestedText;
217    /// assert!(AttestedText::explicit("x", "s").is_consistent());
218    /// assert!(AttestedText::unknown("s", "why").is_consistent());
219    /// ```
220    pub fn is_consistent(&self) -> bool {
221        self.value.is_some() != (self.provenance == FieldProvenance::Unknown)
222    }
223}
224
225/// A list with its provenance. The distinction a bare `Vec` cannot make is the one that
226/// matters here: `depends_on: []` in the record is an authored empty list, and a record with
227/// no `depends_on` key is unknown. Both are empty; only one is a statement.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
229pub struct AttestedList {
230    /// The items, in the order the record or the derivation produced them. Empty for an
231    /// authored empty list and for an unknown field alike; `provenance` tells them apart.
232    pub values: Vec<String>,
233    /// Where they came from.
234    pub provenance: FieldProvenance,
235    /// The one place a reader goes to check them.
236    pub source: String,
237    /// Why the provenance is what it is, when that is worth a sentence.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub reason: Option<String>,
240}
241
242impl AttestedList {
243    /// A list the canonical record carried under this key, including an empty one.
244    pub fn explicit(values: Vec<String>, source: impl Into<String>) -> Self {
245        Self {
246            values,
247            provenance: FieldProvenance::Explicit,
248            source: source.into(),
249            reason: None,
250        }
251    }
252
253    /// A list worked out by a rule that cannot be wrong about a readable repository.
254    pub fn derived(values: Vec<String>, source: impl Into<String>) -> Self {
255        Self {
256            values,
257            provenance: FieldProvenance::Derived,
258            source: source.into(),
259            reason: None,
260        }
261    }
262
263    /// A list worked out by a rule that can be wrong, with the rule stated.
264    pub fn inferred(
265        values: Vec<String>,
266        source: impl Into<String>,
267        reason: impl Into<String>,
268    ) -> Self {
269        Self {
270            values,
271            provenance: FieldProvenance::Inferred,
272            source: source.into(),
273            reason: Some(reason.into()),
274        }
275    }
276
277    /// No list at all, and why there is none.
278    pub fn unknown(source: impl Into<String>, reason: impl Into<String>) -> Self {
279        Self {
280            values: Vec::new(),
281            provenance: FieldProvenance::Unknown,
282            source: source.into(),
283            reason: Some(reason.into()),
284        }
285    }
286
287    /// How many items, which is zero for an unknown list too.
288    pub fn len(&self) -> usize {
289        self.values.len()
290    }
291
292    /// No items — for an authored empty list as well as for an unknown one.
293    pub fn is_empty(&self) -> bool {
294        self.values.is_empty()
295    }
296}
297
298/// A count with its provenance. Separate from [`AttestedText`] because a wave, a commit
299/// tally and an evidence count are numbers a caller does arithmetic on, and a number
300/// rendered as a string is a number every client has to parse back.
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
302pub struct AttestedCount {
303    /// The number, absent exactly when the provenance is `unknown`.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub value: Option<u32>,
306    /// Where it came from.
307    pub provenance: FieldProvenance,
308    /// The one place a reader goes to check it.
309    pub source: String,
310    /// Why the provenance is what it is, when that is worth a sentence.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub reason: Option<String>,
313}
314
315impl AttestedCount {
316    /// A number the canonical record carried under this key.
317    pub fn explicit(value: u32, source: impl Into<String>) -> Self {
318        Self {
319            value: Some(value),
320            provenance: FieldProvenance::Explicit,
321            source: source.into(),
322            reason: None,
323        }
324    }
325
326    /// A number worked out from records or from git.
327    pub fn derived(value: u32, source: impl Into<String>) -> Self {
328        Self {
329            value: Some(value),
330            provenance: FieldProvenance::Derived,
331            source: source.into(),
332            reason: None,
333        }
334    }
335
336    /// No number, and why there is none.
337    pub fn unknown(source: impl Into<String>, reason: impl Into<String>) -> Self {
338        Self {
339            value: None,
340            provenance: FieldProvenance::Unknown,
341            source: source.into(),
342            reason: Some(reason.into()),
343        }
344    }
345}
346
347/// How much of one answer was authored, and how much a machine worked out.
348///
349/// Not decoration: it is the measurement that makes the provenance discipline checkable
350/// from outside. A task whose declaration is mostly `unknown` is a placeholder however
351/// complete its derived half looks, and this is the number that says so.
352#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
353pub struct AttestationTally {
354    /// Fields a person authored in the canonical record.
355    pub explicit: u32,
356    /// Fields worked out by a rule that cannot be wrong about a readable repository.
357    pub derived: u32,
358    /// Fields worked out by a rule that can be wrong.
359    pub inferred: u32,
360    /// Fields not available at all.
361    pub unknown: u32,
362}
363
364impl AttestationTally {
365    /// Count one field.
366    pub fn count(&mut self, p: FieldProvenance) {
367        match p {
368            FieldProvenance::Explicit => self.explicit += 1,
369            FieldProvenance::Derived => self.derived += 1,
370            FieldProvenance::Inferred => self.inferred += 1,
371            FieldProvenance::Unknown => self.unknown += 1,
372        }
373    }
374
375    /// Every field counted.
376    ///
377    /// ```
378    /// use majordomus_cli::devtask::{AttestationTally, FieldProvenance};
379    /// let mut t = AttestationTally::default();
380    /// t.count(FieldProvenance::Explicit);
381    /// t.count(FieldProvenance::Unknown);
382    /// assert_eq!(t.total(), 2);
383    /// ```
384    pub fn total(&self) -> u32 {
385        self.explicit + self.derived + self.inferred + self.unknown
386    }
387}