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}