Skip to main content

majordomus_cli/devtask/
task.rs

1//! One issue read as an executable development task.
2//!
3//! Four groups, one per owner of the facts in it — see the [module documentation] for the
4//! table — and every field of every group attested. Nothing is read here: [`DevTaskModel`]
5//! carries the already-derived plan, the already-parsed record, the already-derived git
6//! trace and the already-resolved session records, and [`DevTask::build`] is a pure
7//! function of them. That is what lets the whole model be unit-tested without a repository
8//! and what keeps the derivations single: this file composes, it does not compute.
9//!
10//! [module documentation]: super
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::plan::{Plan, PlanIssue};
17use crate::worktree::trace::IssueTrace;
18
19use super::readiness::{Blocker, BlockerKind, TaskReadiness, MILESTONE_BLOCKER};
20use super::{AttestationTally, AttestedCount, AttestedList, AttestedText};
21
22/// The adapter that owns the projection of this model onto GitHub.
23///
24/// Named, not reimplemented. This executable makes no network call —
25/// `test/cases/08_no_forbidden_constructs.sh` is the proof — so the live state of the
26/// external record is not a fact any field here can carry, and the honest answer is the
27/// name of the one thing that can answer it. Restating the adapter's reconciliation
28/// vocabulary here would be the repeated semantic definition ADR 0004 forbids.
29pub const PROJECTION_ADAPTER: &str = "scripts/github-sync";
30
31/// The commands that answer the external half of a task's state.
32pub const RECONCILE_WITH: &[&str] = &["scripts/github-sync --check", "scripts/traceability"];
33
34/// Everything [`DevTask::build`] needs, gathered by the caller that has a repository.
35#[derive(Debug)]
36pub struct DevTaskModel<'a> {
37    /// The derived plan: the one derivation of status, waves, dependents and findings.
38    pub plan: &'a Plan,
39    /// The canonical record, when the model declares this issue.
40    pub record: Option<RecordRef<'a>>,
41    /// What git says about the branches and commits that realised it, when git was
42    /// consulted. `None` is not "nothing realised it": it is "git was not asked", and the
43    /// execution group says which.
44    pub trace: Option<&'a IssueTrace>,
45    /// The session records whose branch names this issue, in canonical order. Resolved by
46    /// the caller because the rule belongs to [`crate::worktree::state::issue_of`] and is
47    /// not restated here.
48    pub sessions: Vec<SessionRef>,
49    /// The repository the external projection targets, as `project.yaml` declares it.
50    pub repository: Option<&'a str>,
51}
52
53/// The canonical record: where it is, and what it says.
54#[derive(Debug, Clone, Copy)]
55pub struct RecordRef<'a> {
56    /// Repository-relative path of the file the record was read from.
57    pub path: &'a str,
58    /// The parsed YAML, whole. Key *presence* here is what makes a field explicit, which is
59    /// the one thing the flattened plan cannot tell a reader.
60    pub metadata: &'a Value,
61}
62
63/// One session record that names this issue, and how strongly.
64///
65/// The two ways a session reaches an issue are not equally good, and the difference is the
66/// whole reason this carries a flag rather than being a bare id:
67///
68/// * the record's own `issues` key. `share/schemas/majordomus/session-record` describes it
69///   as "the issues it moved, by id — derived from the ledger's own events for this
70///   episode; never authored", so it is a canonical link that a machine worked out from
71///   events it can read. That is `FieldProvenance::Derived`.
72/// * the branch name. A record always declares the branch it was written on, and
73///   [`crate::worktree::state::issue_of`] reads an issue id out of one. It is a rule about a
74///   name and it can be wrong — a branch renamed, an id that is a prefix of another — so it
75///   is `FieldProvenance::Inferred`.
76///
77/// Reporting both under one provenance would make the weaker of them look like the
78/// stronger, which is the defect this whole module exists to prevent.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80pub struct SessionRef {
81    /// The episode's id.
82    pub session_id: String,
83    /// The branch it was written on.
84    pub branch: String,
85    /// When the record says it was written.
86    pub created_at: String,
87    /// Repository-relative path of the record.
88    pub path: String,
89    /// True when the record's own `issues` key names this issue: a canonical link, not a
90    /// guess about a branch name.
91    pub declares_issue: bool,
92}
93
94// ---------------------------------------------------------------- the groups
95
96/// What a person authored in the canonical record. Every field is `FieldProvenance::Explicit`
97/// or `FieldProvenance::Unknown` and nothing else: a value here was written down, or it
98/// does not exist.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100pub struct DevTaskDeclaration {
101    /// One line naming the outcome.
102    pub title: AttestedText,
103    /// What the issue is for.
104    pub objective: AttestedText,
105    /// Why it is worth doing.
106    pub why: AttestedText,
107    /// Where the repository stands before it.
108    pub current_state: AttestedText,
109    /// Where the repository stands once it is real.
110    pub desired_state: AttestedText,
111    /// What the author says about how far it got. Authored prose, never a status: the
112    /// status is derived, and this is the one field where a person may disagree with it.
113    pub completion: AttestedText,
114    /// The milestone it belongs to.
115    pub milestone: AttestedText,
116    /// `p0` … `p3`.
117    pub priority: AttestedText,
118    /// The execution profile it is worked under.
119    pub profile: AttestedText,
120    /// Whether it may run beside another issue of its wave.
121    pub parallel_safe: AttestedText,
122    /// Who owns it.
123    pub owner: AttestedText,
124    /// What could go wrong.
125    pub risk: AttestedText,
126    /// The slug.
127    pub slug: AttestedText,
128    /// Whether it was withdrawn.
129    pub cancelled: AttestedText,
130    /// When the record was created.
131    pub created_at: AttestedText,
132    /// When it last moved.
133    pub updated_at: AttestedText,
134    /// When execution began.
135    pub started_at: AttestedText,
136    /// When implementation was declared finished.
137    pub verified_at: AttestedText,
138    /// When completion was recorded.
139    pub completed_at: AttestedText,
140    /// The paths it may touch.
141    pub scope: AttestedList,
142    /// The paths it deliberately does not touch.
143    pub non_scope: AttestedList,
144    /// The issues it declares a dependency on, exactly as declared — including one that
145    /// does not exist, which is a diagnostic and not a silent omission.
146    pub depends_on: AttestedList,
147    /// What must be true before it is finished.
148    pub acceptance_criteria: AttestedList,
149    /// The commands that check it.
150    pub validation: AttestedList,
151    /// The evidence tokens it requires before it may be complete.
152    pub evidence_required: AttestedList,
153    /// The evidence tokens it actually carries.
154    pub evidence_present: AttestedList,
155    /// The commits the record's own evidence entries name. Authored, and therefore a
156    /// different fact from the commits git finds on the branches — which are in
157    /// [`DevTaskExecution::commits`] and are derived.
158    pub evidence_commits: AttestedList,
159}
160
161/// Where the task sits in the plan graph. Every field is derived; nothing here is authored,
162/// and nothing here is stored anywhere.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164pub struct DevTaskPosition {
165    /// The canonical status, from the one derivation of it.
166    pub status: AttestedText,
167    /// The execution wave: one past the longest path to it. Unknown when a cycle prevents
168    /// it from ever having one.
169    pub wave: AttestedCount,
170    /// The dependencies that are not `DONE`, plus the milestone gate when it applies.
171    pub blocked_by: AttestedList,
172    /// The issues that wait on this one.
173    pub dependents: AttestedList,
174    /// Every issue that waits on it, directly or through another. The measure of what
175    /// finishing it unblocks.
176    pub transitive_dependents: AttestedCount,
177    /// The status of the milestone it belongs to.
178    pub milestone_status: AttestedText,
179    /// The milestone's layer in the milestone graph.
180    pub milestone_rank: AttestedCount,
181    /// The outcomes that milestone still waits on.
182    pub milestone_blocked_by: AttestedList,
183    /// How many evidence tokens it carries.
184    pub evidence_have: AttestedCount,
185    /// How many it needs.
186    pub evidence_need: AttestedCount,
187}
188
189/// Local execution state: what actually happened in this checkout's git history and session
190/// records. Derived where the relation was declared, inferred where it was not, and the
191/// fields say which.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
193pub struct DevTaskExecution {
194    /// Whether git was consulted at all. False makes every git-derived field below
195    /// `unknown` rather than empty, which is the difference between "nothing realised this"
196    /// and "nobody looked".
197    pub git_consulted: bool,
198    /// The branches whose name carries this issue's id, local first.
199    pub branches: AttestedList,
200    /// Of those, the ones that reached the trunk.
201    pub merged_branches: AttestedList,
202    /// How many distinct commits those branches hold that the trunk did not.
203    pub commits: AttestedCount,
204    /// The trunk every branch was measured against.
205    pub trunk: AttestedText,
206    /// True when every branch's commits could be derived. False when one reached the trunk
207    /// without a merge commit of its own, so part of the work cannot be told from the
208    /// trunk's — reported rather than counted as zero.
209    pub commits_complete: AttestedText,
210    /// The session records whose own `issues` key names this issue. Derived: the key is
211    /// worked out from the episode's ledger events, not authored and not guessed.
212    pub sessions: AttestedList,
213    /// The session records that reach this issue only through their branch name, and which
214    /// therefore may be wrong. Never overlaps [`DevTaskExecution::sessions`]: a session that
215    /// declared the issue is reported there and not here, so the weaker relation never
216    /// stands in for the stronger one.
217    pub sessions_by_branch: AttestedList,
218}
219
220/// The external projection. Nothing here is ever written by this executable, and the fields
221/// that only the adapter can answer say so rather than reading as empty.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223pub struct DevTaskSynchronisation {
224    /// The adapter that owns the projection. The one thing that can answer the live state.
225    pub adapter: String,
226    /// The repository the projection targets, from `project.yaml`.
227    pub repository: AttestedText,
228    /// The reconciliation state of the external record. Always `unknown` from here: it is a
229    /// network fact, and this executable makes none. The reason names the command.
230    pub state: AttestedText,
231    /// The external record's own identifier — a GitHub number — which GitHub assigns and
232    /// nothing local records. Always `unknown`, for the same reason.
233    pub external_id: AttestedText,
234    /// The commands that answer the external half.
235    pub reconcile_with: Vec<String>,
236    /// Whether this executable may write any of it. Always false; stated as a field so that
237    /// a client need not know the rule to respect it.
238    pub writable: bool,
239}
240
241/// The readiness verdict, with everything it was derived from beside it.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243pub struct ReadinessVerdict {
244    /// Where the task stands, as work.
245    pub state: TaskReadiness,
246    /// The canonical status it refines, so the two can never be read apart.
247    pub canonical_status: String,
248    /// Why it is in that state, in one line.
249    pub reason: String,
250    /// May a worker pick it up right now?
251    pub startable: bool,
252    /// Is there nothing further to do?
253    pub terminal: bool,
254    /// Everything standing in the way, each with what to do about it.
255    pub blockers: Vec<Blocker>,
256}
257
258/// One thing wrong with this task's records, in the shape `project.finding-carries-reproduce`
259/// asks for.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
261pub struct DevTaskDiagnostic {
262    /// `FAIL` or `WARN`, as the plan's own finding levels.
263    pub level: String,
264    /// The stable code a reader greps for.
265    pub code: String,
266    /// What is wrong, in one line.
267    pub message: String,
268    /// The one command that shows it again.
269    pub reproduce: String,
270}
271
272// ---------------------------------------------------------------- the task
273
274/// One issue as an executable development task: what was authored, where it sits, what has
275/// happened to it, where its external projection stands, and whether a worker may start it.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
277pub struct DevTask {
278    /// The issue id, as asked for.
279    pub issue: String,
280    /// Whether the canonical model declares it. False is answered rather than refused, with
281    /// every canonical field `unknown`: a typo that read as "nothing has been authored" is
282    /// the one answer this must never give.
283    pub declared: bool,
284    /// Repository-relative path of the canonical record, when there is one.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub record: Option<String>,
287    /// What a person authored.
288    pub declaration: DevTaskDeclaration,
289    /// Where the plan graph puts it.
290    pub position: DevTaskPosition,
291    /// What happened locally.
292    pub execution: DevTaskExecution,
293    /// Where the external projection stands.
294    pub synchronisation: DevTaskSynchronisation,
295    /// Whether it can be worked on, and what is in the way.
296    pub readiness: ReadinessVerdict,
297    /// Everything wrong with its records.
298    pub diagnostics: Vec<DevTaskDiagnostic>,
299    /// How much of the answer was authored and how much a machine worked out.
300    pub attestation: AttestationTally,
301}
302
303/// Is the key present in the record, and does it carry anything? A key present and null is
304/// as good as absent: a reader cannot act on it, and calling it explicit would be the
305/// defect this module exists to prevent.
306fn present<'a>(meta: Option<&'a Value>, key: &str) -> Option<&'a Value> {
307    match meta?.get(key) {
308        None | Some(Value::Null) => None,
309        Some(Value::String(s)) if s.is_empty() => None,
310        Some(v) => Some(v),
311    }
312}
313
314/// One scalar of the record, attested by whether the record carries the key.
315fn authored(record: Option<RecordRef<'_>>, key: &str) -> AttestedText {
316    let source = source_of(record, key);
317    match present(record.map(|r| r.metadata), key) {
318        Some(v) => AttestedText::explicit(crate::plan::scalar(Some(v)), source),
319        None if record.is_none() => AttestedText::unknown(
320            source,
321            "the canonical model declares no issue with this id".to_string(),
322        ),
323        None => AttestedText::unknown(source, format!("the record declares no `{key}`")),
324    }
325}
326
327/// One list of the record, attested by whether the record carries the key — so an authored
328/// empty list is explicit and an absent key is unknown.
329fn authored_list(record: Option<RecordRef<'_>>, key: &str) -> AttestedList {
330    let source = source_of(record, key);
331    match record.map(|r| r.metadata).and_then(|m| m.get(key)) {
332        Some(Value::Array(items)) => AttestedList::explicit(
333            items.iter().map(|v| crate::plan::scalar(Some(v))).collect(),
334            source,
335        ),
336        // A scalar where a list was expected is one item, which is what the flattener
337        // produces for `scope: apps/` too; the plan reads it the same way.
338        Some(other) if !other.is_null() => {
339            AttestedList::explicit(vec![crate::plan::scalar(Some(other))], source)
340        }
341        _ if record.is_none() => {
342            AttestedList::unknown(source, "the canonical model declares no issue with this id")
343        }
344        _ => AttestedList::unknown(source, format!("the record declares no `{key}`")),
345    }
346}
347
348/// Where a reader goes to check one authored field.
349fn source_of(record: Option<RecordRef<'_>>, key: &str) -> String {
350    match record {
351        Some(r) => format!("{}#{key}", r.path),
352        None => format!(".ai/repo/project/issues#{key}"),
353    }
354}
355
356/// The `covers` token of every evidence entry that carries one, and the `commit` of every
357/// entry that carries one. Read from the record rather than from the plan, because the plan
358/// counts them and this names them.
359fn evidence_field(record: Option<RecordRef<'_>>, key: &str) -> AttestedList {
360    let source = source_of(record, &format!("evidence[].{key}"));
361    let Some(RecordRef { metadata, .. }) = record else {
362        return AttestedList::unknown(source, "the canonical model declares no issue with this id");
363    };
364    match metadata.get("evidence") {
365        Some(Value::Array(items)) => AttestedList::explicit(
366            items
367                .iter()
368                .filter_map(|e| e.get(key))
369                .map(|v| crate::plan::scalar(Some(v)))
370                .collect(),
371            source,
372        ),
373        _ => AttestedList::unknown(source, "the record carries no `evidence` entries"),
374    }
375}
376
377impl DevTask {
378    /// The capability id every field of the derived half points a reader at.
379    const PLAN: &'static str = "plan.model";
380    /// The capability id the git-derived half points a reader at.
381    const TRACE: &'static str = "trace.issue";
382
383    /// Compose one development task out of derivations that already exist.
384    ///
385    /// Pure: every input is a value the caller already has, and the only work done here is
386    /// attesting each field and deriving the readiness. A repository is never touched.
387    #[allow(clippy::too_many_lines)] // one composition, field by field, with each attested
388    pub fn build(model: &DevTaskModel<'_>, issue: &str) -> DevTask {
389        let record = model.record;
390        let declared = record.is_some();
391        let derived = model.plan.issue(issue);
392        let milestone = derived.and_then(|i| model.plan.milestone(&i.milestone));
393
394        let declaration = DevTaskDeclaration {
395            title: authored(record, "title"),
396            objective: authored(record, "objective"),
397            why: authored(record, "why"),
398            current_state: authored(record, "current_state"),
399            desired_state: authored(record, "desired_state"),
400            completion: authored(record, "completion"),
401            milestone: authored(record, "milestone"),
402            priority: authored(record, "priority"),
403            profile: authored(record, "profile"),
404            parallel_safe: authored(record, "parallel_safe"),
405            owner: authored(record, "owner"),
406            risk: authored(record, "risk"),
407            slug: authored(record, "slug"),
408            cancelled: authored(record, "cancelled"),
409            created_at: authored(record, "created_at"),
410            updated_at: authored(record, "updated_at"),
411            started_at: authored(record, "started_at"),
412            verified_at: authored(record, "verified_at"),
413            completed_at: authored(record, "completed_at"),
414            scope: authored_list(record, "scope"),
415            non_scope: authored_list(record, "non_scope"),
416            depends_on: authored_list(record, "depends_on"),
417            acceptance_criteria: authored_list(record, "acceptance_criteria"),
418            validation: authored_list(record, "validation"),
419            evidence_required: authored_list(record, "evidence_required"),
420            evidence_present: evidence_field(record, "covers"),
421            evidence_commits: evidence_field(record, "commit"),
422        };
423
424        let position = Self::position(model.plan, derived, milestone);
425        let execution = Self::execution(model.trace, &model.sessions);
426        let synchronisation = Self::synchronisation(model.repository);
427        let readiness = Self::readiness(
428            model.plan,
429            derived,
430            &declaration,
431            milestone.map(|m| m.id.as_str()),
432        );
433        let diagnostics = Self::diagnostics(model.plan, issue);
434
435        let mut task = DevTask {
436            issue: issue.to_string(),
437            declared,
438            record: record.map(|r| r.path.to_string()),
439            declaration,
440            position,
441            execution,
442            synchronisation,
443            readiness,
444            diagnostics,
445            attestation: AttestationTally::default(),
446        };
447        task.attestation = task.tally();
448        task
449    }
450
451    fn position(
452        plan: &Plan,
453        derived: Option<&PlanIssue>,
454        milestone: Option<&crate::plan::PlanMilestone>,
455    ) -> DevTaskPosition {
456        let absent = "the canonical model declares no issue with this id";
457        let Some(i) = derived else {
458            return DevTaskPosition {
459                status: AttestedText::unknown(Self::PLAN, absent),
460                wave: AttestedCount::unknown(Self::PLAN, absent),
461                blocked_by: AttestedList::unknown(Self::PLAN, absent),
462                dependents: AttestedList::unknown(Self::PLAN, absent),
463                transitive_dependents: AttestedCount::unknown(Self::PLAN, absent),
464                milestone_status: AttestedText::unknown(Self::PLAN, absent),
465                milestone_rank: AttestedCount::unknown(Self::PLAN, absent),
466                milestone_blocked_by: AttestedList::unknown(Self::PLAN, absent),
467                evidence_have: AttestedCount::unknown(Self::PLAN, absent),
468                evidence_need: AttestedCount::unknown(Self::PLAN, absent),
469            };
470        };
471        let cyclic = plan
472            .findings
473            .iter()
474            .any(|f| f.code == "cycle" && f.message.split_whitespace().any(|w| w == i.id));
475        DevTaskPosition {
476            status: AttestedText::derived(&i.status, Self::PLAN),
477            wave: if cyclic {
478                AttestedCount::unknown(
479                    Self::PLAN,
480                    "a dependency cycle prevents it from ever having a wave",
481                )
482            } else {
483                AttestedCount::derived(i.wave, Self::PLAN)
484            },
485            blocked_by: AttestedList::derived(i.blocked_by.clone(), Self::PLAN),
486            dependents: AttestedList::derived(i.dependents.clone(), Self::PLAN),
487            transitive_dependents: AttestedCount::derived(
488                u32::try_from(super::graph::transitive_dependents(&plan.issues, &i.id).len())
489                    .unwrap_or(u32::MAX),
490                Self::PLAN,
491            ),
492            milestone_status: milestone.map_or_else(
493                || {
494                    AttestedText::unknown(
495                        Self::PLAN,
496                        "the issue names no milestone the model declares",
497                    )
498                },
499                |m| AttestedText::derived(&m.status, Self::PLAN),
500            ),
501            milestone_rank: milestone.map_or_else(
502                || {
503                    AttestedCount::unknown(
504                        Self::PLAN,
505                        "the issue names no milestone the model declares",
506                    )
507                },
508                |m| AttestedCount::derived(m.rank, Self::PLAN),
509            ),
510            milestone_blocked_by: milestone.map_or_else(
511                || {
512                    AttestedList::unknown(
513                        Self::PLAN,
514                        "the issue names no milestone the model declares",
515                    )
516                },
517                |m| AttestedList::derived(m.blocked_by.clone(), Self::PLAN),
518            ),
519            evidence_have: AttestedCount::derived(i.evidence_have, Self::PLAN),
520            evidence_need: AttestedCount::derived(i.evidence_need, Self::PLAN),
521        }
522    }
523
524    fn execution(trace: Option<&IssueTrace>, sessions: &[SessionRef]) -> DevTaskExecution {
525        let unasked = "git was not consulted for this answer";
526        let declared_field = AttestedList::derived(
527            sessions
528                .iter()
529                .filter(|s| s.declares_issue)
530                .map(|s| s.session_id.clone())
531                .collect(),
532            "kind:session#issues",
533        );
534        let branch_field = AttestedList::inferred(
535            sessions
536                .iter()
537                .filter(|s| !s.declares_issue)
538                .map(|s| s.session_id.clone())
539                .collect(),
540            "kind:session#branch",
541            "the record's own `issues` key does not name this issue; the link is the \
542             branch-name rule of worktree::state::issue_of, which a rename can break",
543        );
544        let Some(t) = trace else {
545            return DevTaskExecution {
546                git_consulted: false,
547                branches: AttestedList::unknown(Self::TRACE, unasked),
548                merged_branches: AttestedList::unknown(Self::TRACE, unasked),
549                commits: AttestedCount::unknown(Self::TRACE, unasked),
550                trunk: AttestedText::unknown(Self::TRACE, unasked),
551                commits_complete: AttestedText::unknown(Self::TRACE, unasked),
552                sessions: declared_field,
553                sessions_by_branch: branch_field,
554            };
555        };
556        DevTaskExecution {
557            git_consulted: true,
558            branches: AttestedList::derived(
559                t.branches.iter().map(|b| b.name.clone()).collect(),
560                Self::TRACE,
561            ),
562            merged_branches: AttestedList::derived(
563                t.branches
564                    .iter()
565                    .filter(|b| b.merge_commit.is_some())
566                    .map(|b| b.name.clone())
567                    .collect(),
568                Self::TRACE,
569            ),
570            commits: AttestedCount::derived(
571                u32::try_from(t.commits).unwrap_or(u32::MAX),
572                Self::TRACE,
573            ),
574            trunk: t.trunk.as_ref().map_or_else(
575                || AttestedText::unknown(Self::TRACE, "this repository has no trunk ref"),
576                |trunk| AttestedText::derived(trunk, Self::TRACE),
577            ),
578            commits_complete: AttestedText::derived(t.complete.to_string(), Self::TRACE),
579            sessions: declared_field,
580            sessions_by_branch: branch_field,
581        }
582    }
583
584    fn synchronisation(repository: Option<&str>) -> DevTaskSynchronisation {
585        let network = format!(
586            "a live GitHub state; this executable makes no network call, so only \
587             `{PROJECTION_ADAPTER} --check` can answer it"
588        );
589        DevTaskSynchronisation {
590            adapter: PROJECTION_ADAPTER.to_string(),
591            repository: repository.map_or_else(
592                || {
593                    // Deliberately not "project.yaml names no repository". In this
594                    // repository the file does name one and the header is empty anyway:
595                    // `.ai/repo/knowledge/sources.yaml` declares a source class for
596                    // `project/milestones/*.yaml` and one for `project/issues/*.yaml` and
597                    // none for `project/project.yaml`, so the `project` kind has no objects
598                    // and `Plan::project` is blank for every reader of it. Saying the file
599                    // is silent would be this module inventing a fact about a file it did
600                    // not read, which is the whole thing it exists not to do.
601                    AttestedText::unknown(
602                        "plan.model#project.repository",
603                        "the derived plan's header carries no repository",
604                    )
605                },
606                |r| AttestedText::explicit(r, ".ai/repo/project/project.yaml#repository"),
607            ),
608            state: AttestedText::unknown(PROJECTION_ADAPTER, network.clone()),
609            external_id: AttestedText::unknown(PROJECTION_ADAPTER, network),
610            reconcile_with: RECONCILE_WITH.iter().map(|c| (*c).to_string()).collect(),
611            writable: false,
612        }
613    }
614
615    fn readiness(
616        plan: &Plan,
617        derived: Option<&PlanIssue>,
618        declaration: &DevTaskDeclaration,
619        milestone: Option<&str>,
620    ) -> ReadinessVerdict {
621        let Some(i) = derived else {
622            return ReadinessVerdict {
623                state: TaskReadiness::Undeclared,
624                canonical_status: String::new(),
625                reason: "the canonical model declares no issue with this id".into(),
626                startable: false,
627                terminal: false,
628                blockers: vec![Blocker::new(
629                    BlockerKind::Reference,
630                    "",
631                    "no canonical record; author one under .ai/repo/project/issues/ \
632                     before anything can be executed against it",
633                )],
634            };
635        };
636        let completion_recorded = !declaration.completed_at.text().is_empty();
637        let evidence_covered = i.evidence_have >= i.evidence_need;
638        let state = TaskReadiness::derive(
639            &i.status,
640            &i.blocked_by,
641            completion_recorded,
642            evidence_covered,
643        );
644        let mut blockers: Vec<Blocker> = Vec::new();
645        for b in &i.blocked_by {
646            if let Some(m) = b.strip_prefix(MILESTONE_BLOCKER) {
647                blockers.push(Blocker::new(
648                    BlockerKind::Milestone,
649                    m,
650                    format!("the milestone {m} waits on an outcome of its own"),
651                ));
652            } else {
653                blockers.push(Blocker::new(
654                    BlockerKind::Issue,
655                    b,
656                    format!("{b} is not DONE"),
657                ));
658            }
659        }
660        // A dependency the model does not declare cannot appear in `blocked_by` — the plan
661        // drops the edge and reports `unknown_dependency` — so it is named here from the
662        // declaration, or it would be a blocker nobody could see from this surface.
663        for d in &declaration.depends_on.values {
664            if plan.issue(d).is_none() && d != &i.id {
665                blockers.push(Blocker::new(
666                    BlockerKind::Reference,
667                    d,
668                    format!(
669                        "{d} is declared as a dependency and the model has no such issue; \
670                         fix the reference or author the record"
671                    ),
672                ));
673            }
674        }
675        // A cycle is reported once, about the whole graph; an issue in it or downstream of
676        // it can never become ready, and that is a blocker of its own kind.
677        for f in &plan.findings {
678            if f.code == "cycle" && f.message.split_whitespace().any(|w| w == i.id) {
679                blockers.push(Blocker::new(
680                    BlockerKind::Cycle,
681                    &i.id,
682                    "a dependency cycle prevents it from ever becoming ready".to_string(),
683                ));
684            }
685        }
686        if state == TaskReadiness::CompletionBlocked {
687            let have = &declaration.evidence_present.values;
688            for need in &declaration.evidence_required.values {
689                if !have.contains(need) {
690                    blockers.push(Blocker::new(
691                        BlockerKind::Evidence,
692                        need,
693                        format!(
694                            "completion is recorded and no evidence entry covers `{need}`; \
695                             record it with `majordomus evidence`"
696                        ),
697                    ));
698                }
699            }
700        }
701        let reason = match state {
702            TaskReadiness::Ready => {
703                "no dependency and no gate holds it; nobody has started it".into()
704            }
705            TaskReadiness::Blocked => format!(
706                "{} of its dependencies {} not DONE",
707                blockers
708                    .iter()
709                    .filter(|b| b.kind == BlockerKind::Issue)
710                    .count(),
711                if blockers
712                    .iter()
713                    .filter(|b| b.kind == BlockerKind::Issue)
714                    .count()
715                    == 1
716                {
717                    "is"
718                } else {
719                    "are"
720                }
721            ),
722            TaskReadiness::Waiting => format!(
723                "every dependency is satisfied; the milestone {} waits on an outcome of its own",
724                milestone.unwrap_or("it belongs to")
725            ),
726            TaskReadiness::InProgress => "execution began; the record carries started_at".into(),
727            TaskReadiness::Review => {
728                "implementation is declared finished and completion is not recorded".into()
729            }
730            TaskReadiness::CompletionBlocked => {
731                "completion is recorded and the evidence the record requires is not all there"
732                    .into()
733            }
734            TaskReadiness::Complete => {
735                "completion is recorded and every required evidence token is present".into()
736            }
737            TaskReadiness::Cancelled => "withdrawn".into(),
738            TaskReadiness::Undeclared => format!(
739                "the plan assigned the status `{}`, which its own vocabulary does not declare",
740                i.status
741            ),
742        };
743        ReadinessVerdict {
744            state,
745            canonical_status: i.status.clone(),
746            reason,
747            startable: state.is_startable(),
748            terminal: state.is_terminal(),
749            blockers,
750        }
751    }
752
753    /// Every plan finding whose subject is this issue, plus the graph-wide ones that name
754    /// it. The plan is the only validator; this filters its findings, and adds none.
755    fn diagnostics(plan: &Plan, issue: &str) -> Vec<DevTaskDiagnostic> {
756        plan.findings
757            .iter()
758            .filter(|f| {
759                f.subject == issue
760                    || (f.subject == "graph" && f.message.split_whitespace().any(|w| w == issue))
761            })
762            .map(|f| DevTaskDiagnostic {
763                level: f.level.clone(),
764                code: f.code.clone(),
765                message: f.message.clone(),
766                reproduce: "majordomus plan validate".into(),
767            })
768            .collect()
769    }
770
771    /// Count every attested field of this task.
772    fn tally(&self) -> AttestationTally {
773        let mut t = AttestationTally::default();
774        let d = &self.declaration;
775        for f in [
776            &d.title,
777            &d.objective,
778            &d.why,
779            &d.current_state,
780            &d.desired_state,
781            &d.completion,
782            &d.milestone,
783            &d.priority,
784            &d.profile,
785            &d.parallel_safe,
786            &d.owner,
787            &d.risk,
788            &d.slug,
789            &d.cancelled,
790            &d.created_at,
791            &d.updated_at,
792            &d.started_at,
793            &d.verified_at,
794            &d.completed_at,
795            &self.position.status,
796            &self.position.milestone_status,
797            &self.execution.trunk,
798            &self.execution.commits_complete,
799            &self.synchronisation.repository,
800            &self.synchronisation.state,
801            &self.synchronisation.external_id,
802        ] {
803            t.count(f.provenance);
804        }
805        for f in [
806            &d.scope,
807            &d.non_scope,
808            &d.depends_on,
809            &d.acceptance_criteria,
810            &d.validation,
811            &d.evidence_required,
812            &d.evidence_present,
813            &d.evidence_commits,
814            &self.position.blocked_by,
815            &self.position.dependents,
816            &self.position.milestone_blocked_by,
817            &self.execution.branches,
818            &self.execution.merged_branches,
819            &self.execution.sessions,
820            &self.execution.sessions_by_branch,
821        ] {
822            t.count(f.provenance);
823        }
824        for f in [
825            &self.position.wave,
826            &self.position.transitive_dependents,
827            &self.position.milestone_rank,
828            &self.position.evidence_have,
829            &self.position.evidence_need,
830            &self.execution.commits,
831        ] {
832            t.count(f.provenance);
833        }
834        t
835    }
836
837    /// Every attested field of this task, so a check can walk them without knowing the
838    /// shape. The invariant is the one [`AttestedText::is_consistent`] states.
839    pub fn is_consistent(&self) -> bool {
840        let d = &self.declaration;
841        [
842            &d.title,
843            &d.objective,
844            &d.milestone,
845            &d.completed_at,
846            &self.position.status,
847            &self.synchronisation.state,
848        ]
849        .iter()
850        .all(|f| f.is_consistent())
851            && self.attestation.total() > 0
852    }
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858    use crate::devtask::FieldProvenance;
859    use crate::plan::{PlanCounts, PlanMilestone, PlanProject, PlanVocabulary};
860    use serde_json::json;
861    use std::collections::BTreeMap;
862
863    fn issue(id: &str, milestone: &str, status: &str) -> PlanIssue {
864        PlanIssue {
865            id: id.into(),
866            milestone: milestone.into(),
867            status: status.into(),
868            wave: 0,
869            priority: "p1".into(),
870            profile: "implementation".into(),
871            parallel_safe: true,
872            title: format!("{id} title"),
873            slug: String::new(),
874            depends_on: Vec::new(),
875            blocked_by: Vec::new(),
876            dependents: Vec::new(),
877            scope: Vec::new(),
878            serves: Vec::new(),
879            objective: String::new(),
880            evidence_have: 0,
881            evidence_need: 0,
882            started_at: String::new(),
883            verified_at: String::new(),
884            completed_at: String::new(),
885        }
886    }
887
888    fn milestone(id: &str, status: &str) -> PlanMilestone {
889        PlanMilestone {
890            id: id.into(),
891            status: status.into(),
892            order: 0,
893            priority: "p1".into(),
894            title: format!("{id} title"),
895            slug: String::new(),
896            version: String::new(),
897            rank: 0,
898            depends_on: Vec::new(),
899            blocked_by: Vec::new(),
900            dependents: Vec::new(),
901            claims: Vec::new(),
902            counts: PlanCounts {
903                total: 0,
904                required: 0,
905                by_status: BTreeMap::new(),
906            },
907            issues: Vec::new(),
908            outcome: String::new(),
909        }
910    }
911
912    pub(super) fn plan_of(milestones: Vec<PlanMilestone>, issues: Vec<PlanIssue>) -> Plan {
913        Plan {
914            project: PlanProject {
915                name: "fixture".into(),
916                repository: "owner/fixture".into(),
917                default_branch: "master".into(),
918                active_milestone: String::new(),
919            },
920            statuses: PlanVocabulary {
921                issue: crate::plan::ISSUE_STATUSES
922                    .iter()
923                    .map(|s| (*s).to_string())
924                    .collect(),
925                milestone: crate::plan::MILESTONE_STATUSES
926                    .iter()
927                    .map(|s| (*s).to_string())
928                    .collect(),
929            },
930            milestones,
931            issues,
932            waves: Vec::new(),
933            edges: Vec::new(),
934            milestone_edges: Vec::new(),
935            findings: Vec::new(),
936        }
937    }
938
939    /// An id the model does not declare is answered, not refused — and every canonical
940    /// field of that answer is `unknown` rather than an empty string that reads as authored.
941    #[test]
942    fn an_undeclared_issue_is_answered_with_nothing_claiming_to_be_authored() {
943        let plan = plan_of(Vec::new(), Vec::new());
944        let model = DevTaskModel {
945            plan: &plan,
946            record: None,
947            trace: None,
948            sessions: Vec::new(),
949            repository: Some("owner/fixture"),
950        };
951        let t = DevTask::build(&model, "I9999");
952        assert!(!t.declared);
953        assert_eq!(t.readiness.state, TaskReadiness::Undeclared);
954        assert_eq!(t.declaration.title.provenance, FieldProvenance::Unknown);
955        assert!(t.declaration.title.value.is_none());
956        assert_eq!(t.position.status.provenance, FieldProvenance::Unknown);
957        assert_eq!(t.attestation.explicit, 1, "only the repository is authored");
958        assert!(t.is_consistent());
959    }
960
961    /// A key the record does not carry is `unknown`; a key it carries is `explicit`. The
962    /// distinction the flattened plan cannot make, and the one this module is for.
963    #[test]
964    fn an_absent_key_is_unknown_and_a_present_one_is_explicit() {
965        let meta = json!({
966            "id": "I0001",
967            "milestone": "m1",
968            "title": "Ship it",
969            "depends_on": [],
970        });
971        let plan = plan_of(
972            vec![milestone("m1", "ACTIVE")],
973            vec![issue("I0001", "m1", "READY")],
974        );
975        let model = DevTaskModel {
976            plan: &plan,
977            record: Some(RecordRef {
978                path: ".ai/repo/project/issues/I0001.yaml",
979                metadata: &meta,
980            }),
981            trace: None,
982            sessions: Vec::new(),
983            repository: None,
984        };
985        let t = DevTask::build(&model, "I0001");
986        assert_eq!(t.declaration.title.provenance, FieldProvenance::Explicit);
987        assert_eq!(t.declaration.title.text(), "Ship it");
988        assert_eq!(
989            t.declaration.title.source,
990            ".ai/repo/project/issues/I0001.yaml#title"
991        );
992        // authored as an empty list: explicit and empty, not unknown
993        assert_eq!(
994            t.declaration.depends_on.provenance,
995            FieldProvenance::Explicit
996        );
997        assert!(t.declaration.depends_on.is_empty());
998        // absent entirely
999        assert_eq!(t.declaration.objective.provenance, FieldProvenance::Unknown);
1000        assert!(t.declaration.objective.reason.is_some());
1001    }
1002
1003    /// The status is never re-derived here: it is the plan's, marked derived, pointing at
1004    /// the capability that owns it.
1005    #[test]
1006    fn the_status_is_the_plans_and_says_so() {
1007        let plan = plan_of(
1008            vec![milestone("m1", "ACTIVE")],
1009            vec![issue("I0001", "m1", "ACTIVE")],
1010        );
1011        let meta = json!({ "id": "I0001", "milestone": "m1" });
1012        let model = DevTaskModel {
1013            plan: &plan,
1014            record: Some(RecordRef {
1015                path: "p.yaml",
1016                metadata: &meta,
1017            }),
1018            trace: None,
1019            sessions: Vec::new(),
1020            repository: None,
1021        };
1022        let t = DevTask::build(&model, "I0001");
1023        assert_eq!(t.position.status.text(), "ACTIVE");
1024        assert_eq!(t.position.status.provenance, FieldProvenance::Derived);
1025        assert_eq!(t.position.status.source, "plan.model");
1026        assert_eq!(t.readiness.state, TaskReadiness::InProgress);
1027        assert_eq!(t.readiness.canonical_status, "ACTIVE");
1028    }
1029
1030    /// Git not consulted is `unknown`, not an empty list. "Nobody looked" and "nothing
1031    /// realised it" are different answers and a work surface must not merge them.
1032    #[test]
1033    fn git_not_consulted_is_unknown_rather_than_empty() {
1034        let plan = plan_of(
1035            vec![milestone("m1", "ACTIVE")],
1036            vec![issue("I0001", "m1", "READY")],
1037        );
1038        let meta = json!({ "id": "I0001", "milestone": "m1" });
1039        let model = DevTaskModel {
1040            plan: &plan,
1041            record: Some(RecordRef {
1042                path: "p.yaml",
1043                metadata: &meta,
1044            }),
1045            trace: None,
1046            sessions: Vec::new(),
1047            repository: None,
1048        };
1049        let t = DevTask::build(&model, "I0001");
1050        assert!(!t.execution.git_consulted);
1051        assert_eq!(t.execution.branches.provenance, FieldProvenance::Unknown);
1052        assert_eq!(t.execution.commits.provenance, FieldProvenance::Unknown);
1053    }
1054
1055    /// A session is linked by a branch name and never by a declaration, so the field is
1056    /// inferred and carries the rule. Several sessions of one issue is the ordinary case.
1057    #[test]
1058    fn a_declared_session_link_is_derived_and_a_branch_guess_is_inferred() {
1059        let plan = plan_of(
1060            vec![milestone("m1", "ACTIVE")],
1061            vec![issue("I0001", "m1", "ACTIVE")],
1062        );
1063        let meta = json!({ "id": "I0001", "milestone": "m1" });
1064        let model = DevTaskModel {
1065            plan: &plan,
1066            record: Some(RecordRef {
1067                path: "p.yaml",
1068                metadata: &meta,
1069            }),
1070            trace: None,
1071            sessions: vec![
1072                SessionRef {
1073                    session_id: "s-1".into(),
1074                    branch: "feature/I0001-a".into(),
1075                    created_at: "2026-01-01T00:00:00Z".into(),
1076                    path: "a.md".into(),
1077                    declares_issue: true,
1078                },
1079                SessionRef {
1080                    session_id: "s-2".into(),
1081                    branch: "feature/I0001-b".into(),
1082                    created_at: "2026-01-02T00:00:00Z".into(),
1083                    path: "b.md".into(),
1084                    declares_issue: false,
1085                },
1086            ],
1087            repository: None,
1088        };
1089        let t = DevTask::build(&model, "I0001");
1090        // the record that declared the issue is a canonical link
1091        assert_eq!(t.execution.sessions.provenance, FieldProvenance::Derived);
1092        assert_eq!(t.execution.sessions.values, ["s-1"]);
1093        // the one reached only by its branch name is not allowed to look the same
1094        assert_eq!(
1095            t.execution.sessions_by_branch.provenance,
1096            FieldProvenance::Inferred
1097        );
1098        assert_eq!(t.execution.sessions_by_branch.values, ["s-2"]);
1099        assert!(
1100            t.execution.sessions_by_branch.reason.is_some(),
1101            "an inferred field states its rule"
1102        );
1103        // and neither list stands in for the other
1104        assert!(t.execution.sessions.values.iter().all(|s| !t
1105            .execution
1106            .sessions_by_branch
1107            .values
1108            .contains(s)));
1109    }
1110
1111    /// The external half is never claimed and never writable, and the reason names the one
1112    /// thing that can answer it.
1113    #[test]
1114    fn the_external_projection_is_unknown_and_not_writable() {
1115        let plan = plan_of(Vec::new(), Vec::new());
1116        let model = DevTaskModel {
1117            plan: &plan,
1118            record: None,
1119            trace: None,
1120            sessions: Vec::new(),
1121            repository: Some("owner/fixture"),
1122        };
1123        let t = DevTask::build(&model, "I0001");
1124        assert!(!t.synchronisation.writable);
1125        assert_eq!(t.synchronisation.adapter, PROJECTION_ADAPTER);
1126        assert_eq!(t.synchronisation.state.provenance, FieldProvenance::Unknown);
1127        assert!(t
1128            .synchronisation
1129            .state
1130            .reason
1131            .as_deref()
1132            .unwrap()
1133            .contains(PROJECTION_ADAPTER));
1134        // the repository the projection targets is authored, in project.yaml
1135        assert_eq!(
1136            t.synchronisation.repository.provenance,
1137            FieldProvenance::Explicit
1138        );
1139    }
1140
1141    /// Completion recorded without the evidence the record requires is its own state, and
1142    /// each missing token is a blocker naming what to do.
1143    #[test]
1144    fn completion_without_evidence_is_completion_blocked_with_the_tokens_named() {
1145        let mut i = issue("I0001", "m1", "VERIFY");
1146        i.evidence_have = 1;
1147        i.evidence_need = 2;
1148        let plan = plan_of(vec![milestone("m1", "ACTIVE")], vec![i]);
1149        let meta = json!({
1150            "id": "I0001",
1151            "milestone": "m1",
1152            "completed_at": "2026-01-01",
1153            "evidence_required": ["a", "b"],
1154            "evidence": [{ "covers": "a" }],
1155        });
1156        let model = DevTaskModel {
1157            plan: &plan,
1158            record: Some(RecordRef {
1159                path: "p.yaml",
1160                metadata: &meta,
1161            }),
1162            trace: None,
1163            sessions: Vec::new(),
1164            repository: None,
1165        };
1166        let t = DevTask::build(&model, "I0001");
1167        assert_eq!(t.readiness.state, TaskReadiness::CompletionBlocked);
1168        assert_eq!(t.readiness.canonical_status, "VERIFY");
1169        let evidence: Vec<&str> = t
1170            .readiness
1171            .blockers
1172            .iter()
1173            .filter(|b| b.kind == BlockerKind::Evidence)
1174            .map(|b| b.subject.as_str())
1175            .collect();
1176        assert_eq!(evidence, ["b"]);
1177    }
1178
1179    /// Held back only by the milestone gate is `waiting`, and the blocker says which
1180    /// milestone rather than naming a peer issue that is not the problem.
1181    #[test]
1182    fn only_the_milestone_gate_is_waiting() {
1183        let mut i = issue("I0001", "m1", "BLOCKED");
1184        i.blocked_by = vec!["milestone:m1".into()];
1185        let mut m = milestone("m1", "BLOCKED");
1186        m.blocked_by = vec!["m0".into()];
1187        let plan = plan_of(vec![m], vec![i]);
1188        let meta = json!({ "id": "I0001", "milestone": "m1" });
1189        let model = DevTaskModel {
1190            plan: &plan,
1191            record: Some(RecordRef {
1192                path: "p.yaml",
1193                metadata: &meta,
1194            }),
1195            trace: None,
1196            sessions: Vec::new(),
1197            repository: None,
1198        };
1199        let t = DevTask::build(&model, "I0001");
1200        assert_eq!(t.readiness.state, TaskReadiness::Waiting);
1201        assert_eq!(t.readiness.blockers.len(), 1);
1202        assert_eq!(t.readiness.blockers[0].kind, BlockerKind::Milestone);
1203        assert_eq!(t.readiness.blockers[0].subject, "m1");
1204    }
1205
1206    /// An issue that names no milestone still answers, with the milestone-derived fields
1207    /// unknown and the reason saying why — the plan's own `no_milestone` failure reaches
1208    /// the diagnostics.
1209    #[test]
1210    fn an_issue_without_a_milestone_answers_with_the_milestone_fields_unknown() {
1211        let mut plan = plan_of(Vec::new(), vec![issue("I0001", "", "READY")]);
1212        plan.findings.push(crate::plan::PlanFinding {
1213            level: "FAIL".into(),
1214            code: "no_milestone".into(),
1215            subject: "I0001".into(),
1216            message: "names no milestone".into(),
1217        });
1218        let meta = json!({ "id": "I0001" });
1219        let model = DevTaskModel {
1220            plan: &plan,
1221            record: Some(RecordRef {
1222                path: "p.yaml",
1223                metadata: &meta,
1224            }),
1225            trace: None,
1226            sessions: Vec::new(),
1227            repository: None,
1228        };
1229        let t = DevTask::build(&model, "I0001");
1230        assert!(t.declared);
1231        assert_eq!(t.declaration.milestone.provenance, FieldProvenance::Unknown);
1232        assert_eq!(
1233            t.position.milestone_status.provenance,
1234            FieldProvenance::Unknown
1235        );
1236        assert_eq!(t.readiness.state, TaskReadiness::Ready);
1237        assert!(t.diagnostics.iter().any(|d| d.code == "no_milestone"));
1238        assert!(t.diagnostics.iter().all(|d| !d.reproduce.is_empty()));
1239    }
1240}