Skip to main content

majordomus_cli/capability/builtin/
devtask.rs

1//! The `devtask` module: an issue and a milestone read as executable development scopes.
2//!
3//! # Why this is a module and not two more `plan.*` capabilities
4//!
5//! Every capability is composed into exactly one Rust module, and a module's id is the
6//! namespace of everything in it, so `plan.task` would have to live inside
7//! `builtin/plan.rs`. It does not belong there: `plan` is the projection of
8//! [`crate::plan`], which is held to byte equality with `lib/project.awk` by
9//! `test/cases/99_plan_capabilities.sh`, and a capability that joins the plan to git and to
10//! the session records has no counterpart in the awk to be equal to. Keeping them apart
11//! keeps that equality checkable.
12//!
13//! What it must not be is a second project model, and it is not:
14//!
15//! * the records are the canonical ones, read through the index like every other object;
16//! * the status, wave, dependents, edges and findings are [`crate::plan`]'s, unchanged —
17//!   `the_status_never_disagrees_with_the_plan` is the assertion;
18//! * the branches and commits are `worktree::trace`'s, through the same [`Tracer`]
19//!   `trace.issue` uses;
20//! * the readiness vocabulary is a projection of the canonical statuses and refines three
21//!   of them without replacing any — [`crate::devtask::readiness`] states which and why;
22//! * the external projection stays `scripts/github-sync`'s. Nothing here writes, and
23//!   nothing here restates its reconciliation vocabulary.
24//!
25//! [`Tracer`]: crate::worktree::trace::Tracer
26//!
27//! # Why the milestone graph does not touch git
28//!
29//! `devtask.milestone` is a pure function of the index, and deliberately: a graph whose
30//! ordering depended on `git for-each-ref` would be a graph that differs between a full
31//! clone and a shallow one, and this repository has already been bitten by a derivation
32//! that was machine-dependent. `devtask.issue` consults git because a single issue's
33//! branches are the question being asked; the graph does not, because they are not.
34
35use std::path::Path;
36
37use schemars::JsonSchema;
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
42use crate::capability::handler::{CapabilityError, Context};
43use crate::capability::model::{CachePolicy, CliExposure, Exposure, Stability};
44use crate::capability::module::ModuleDescriptor;
45use crate::devtask::task::{DevTaskModel, RecordRef, SessionRef};
46use crate::devtask::{DevTask, MilestoneGraph};
47use crate::index::Index;
48use crate::plan::{Plan, ISSUE, MILESTONE};
49use crate::worktree::trace::{IssueTrace, Tracer};
50use crate::worktree::WorktreeError;
51use crate::{capability, module};
52
53use super::{get, mcp};
54
55// ---------------------------------------------------------------- inputs
56
57#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59/// Which issue to read as a development task.
60pub struct DevTaskInput {
61    /// The issue id, as the canonical model spells it (`I0901`). An id the model does not
62    /// declare is answered with `declared: false` and every canonical field `unknown`,
63    /// never refused and never answered with empty strings that read as authored.
64    pub issue: String,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    /// Consult git for the branches and commits that realised it. Default: yes. Set it to
67    /// false for a deterministic answer that depends on the records alone — which is what a
68    /// generated document or a comparison across machines wants.
69    pub git: Option<bool>,
70}
71
72impl BenchmarkCases for DevTaskInput {
73    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
74        // An issue of the repository the benchmark runs against, so the case measures the
75        // derivation rather than the refusal. A hard-coded id would be a second declaration
76        // of this repository's own plan.
77        let issue = first(ctx.index, ISSUE).unwrap_or_else(|| "I0001".to_string());
78        vec![
79            NamedCase::new(
80                "records-only",
81                DevTaskInput {
82                    issue: issue.clone(),
83                    git: Some(false),
84                },
85            ),
86            NamedCase::new(
87                "with-git",
88                DevTaskInput {
89                    issue,
90                    git: Some(true),
91                },
92            ),
93        ]
94    }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
98#[serde(deny_unknown_fields)]
99/// Which milestone to read as a dependency graph.
100pub struct DevMilestoneInput {
101    /// The milestone id, as the canonical model spells it. An id the model does not declare
102    /// is answered with an empty graph and every field `unknown`, not refused.
103    pub milestone: String,
104}
105
106impl BenchmarkCases for DevMilestoneInput {
107    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
108        let milestone = first(ctx.index, MILESTONE).unwrap_or_else(|| "M000".to_string());
109        vec![NamedCase::new(
110            "first-milestone",
111            DevMilestoneInput { milestone },
112        )]
113    }
114}
115
116// ---------------------------------------------------------------- what the index holds
117
118/// The first object of a kind, in URI order. A benchmark case's input, and nothing else: a
119/// capability never picks an object for a caller.
120fn first(index: &Index, kind: &str) -> Option<String> {
121    index
122        .objects
123        .iter()
124        .find(|o| o.kind == kind)
125        .map(|o| o.identity.clone())
126}
127
128/// One canonical record of a kind, by identity: its repository-relative path and its parsed
129/// metadata. The index has already discovered, parsed and schema-validated it, so this is
130/// the model itself rather than a second reading of the directory it lives in.
131fn record<'a>(index: &'a Index, kind: &str, identity: &str) -> Option<(&'a str, &'a Value)> {
132    index
133        .objects
134        .iter()
135        .find(|o| o.kind == kind && o.identity == identity)
136        .map(|o| (o.provenance.path.as_str(), &o.metadata))
137}
138
139/// Every issue id the canonical model declares, in id order. What the tracer needs in order
140/// to read an issue out of a branch name.
141fn issue_ids(index: &Index) -> Vec<String> {
142    let mut ids: Vec<String> = index
143        .objects
144        .iter()
145        .filter(|o| o.kind == ISSUE)
146        .map(|o| o.identity.clone())
147        .collect();
148    crate::order::canonical(&mut ids);
149    ids
150}
151
152/// The session records that reach this issue, each carrying how strongly.
153///
154/// Two relations, and they are deliberately not merged. A record's own `issues` key is what
155/// `share/schemas/majordomus/session-record` calls "the issues it moved, by id — derived
156/// from the ledger's own events for this episode; never authored": a canonical link. Its
157/// branch name is a rule about a string — [`crate::worktree::state::issue_of`]'s, not
158/// restated here — which a rename can break. The first is derived, the second inferred, and
159/// [`SessionRef::declares_issue`] is which, so that the field carrying each can state its
160/// own provenance instead of one of them borrowing the other's.
161fn sessions_of(index: &Index, issue: &str) -> Vec<SessionRef> {
162    let ids = vec![issue.to_string()];
163    let mut out: Vec<SessionRef> = index
164        .objects
165        .iter()
166        .filter(|o| o.kind == "session")
167        .filter_map(|o| {
168            let branch = o
169                .metadata
170                .get("branch")
171                .and_then(Value::as_str)
172                .unwrap_or_default();
173            let declares_issue = o
174                .metadata
175                .get("issues")
176                .and_then(Value::as_array)
177                .is_some_and(|items| items.iter().any(|i| i.as_str() == Some(issue)));
178            // a record that neither declares the issue nor carries a branch naming it is
179            // not this issue's session, and is left out rather than reported weakly
180            if !declares_issue && crate::worktree::state::issue_of(branch, &ids).is_none() {
181                return None;
182            }
183            Some(SessionRef {
184                session_id: o.identity.clone(),
185                branch: branch.to_string(),
186                created_at: o
187                    .metadata
188                    .get("created_at")
189                    .and_then(Value::as_str)
190                    .unwrap_or_default()
191                    .to_string(),
192                path: o.provenance.path.clone(),
193                declares_issue,
194            })
195        })
196        .collect();
197    out.sort_by(|a, b| {
198        crate::order::natural_cmp(&a.created_at, &b.created_at)
199            .then_with(|| crate::order::natural_cmp(&a.session_id, &b.session_id))
200    });
201    out
202}
203
204/// The repository the external projection targets, as `project.yaml` declares it.
205fn repository_of(plan: &Plan) -> Option<&str> {
206    Some(plan.project.repository.as_str()).filter(|r| !r.is_empty())
207}
208
209fn refused(e: WorktreeError) -> CapabilityError {
210    match e.exit_code() {
211        crate::worktree::EXIT_MISSING => CapabilityError::NotFound(e.to_string()),
212        crate::worktree::EXIT_REFUSED => CapabilityError::Refused(e.to_string()),
213        _ => CapabilityError::Internal(e.to_string()),
214    }
215}
216
217/// What git says about one issue, or nothing when git could not be opened at all.
218///
219/// A repository this process cannot read as a git repository is not an error here: the
220/// canonical half of the answer is still the whole canonical half, and the execution group
221/// reports `git_consulted: false` rather than an empty list that would read as "nothing
222/// realised this issue".
223fn trace_of(ctx: &Context, issue: &str) -> Result<Option<IssueTrace>, CapabilityError> {
224    let tracer = match Tracer::open(Path::new(&ctx.index.repository.root)) {
225        Ok(t) => t.with_issues(issue_ids(&ctx.index)),
226        Err(e) if e.exit_code() == crate::worktree::EXIT_MISSING => return Ok(None),
227        Err(e) => return Err(refused(e)),
228    };
229    tracer.trace(issue).map(Some).map_err(refused)
230}
231
232// ---------------------------------------------------------------- handlers
233
234fn devtask_issue(ctx: &Context, input: DevTaskInput) -> Result<DevTask, CapabilityError> {
235    let plan = Plan::build(&ctx.index);
236    let trace = if input.git.unwrap_or(true) {
237        trace_of(ctx, &input.issue)?
238    } else {
239        None
240    };
241    let record = record(&ctx.index, ISSUE, &input.issue);
242    let model = DevTaskModel {
243        plan: &plan,
244        record: record.map(|(path, metadata)| RecordRef { path, metadata }),
245        trace: trace.as_ref(),
246        sessions: sessions_of(&ctx.index, &input.issue),
247        repository: repository_of(&plan),
248    };
249    Ok(DevTask::build(&model, &input.issue))
250}
251
252fn devtask_milestone(
253    ctx: &Context,
254    input: DevMilestoneInput,
255) -> Result<MilestoneGraph, CapabilityError> {
256    let plan = Plan::build(&ctx.index);
257    let record = record(&ctx.index, MILESTONE, &input.milestone)
258        .map(|(path, metadata)| RecordRef { path, metadata });
259    Ok(MilestoneGraph::build(&plan, &input.milestone, record))
260}
261
262// ---------------------------------------------------------------- the module
263
264/// The `devtask` module: the canonical development task, composed from derivations that
265/// already exist.
266pub fn module() -> ModuleDescriptor {
267    module! {
268        id: "devtask",
269        title: "Executable development scopes",
270        description: "An issue and a milestone read as work to be done rather than as metadata: what a person authored, where the plan graph puts it, what happened to it locally, where its external projection stands, and whether a worker may start it — with the origin of every value on the value. Nothing here is a second project model: the records are the canonical ones, the status and the graph are `plan`'s, the branches and commits are `trace`'s, the readiness vocabulary refines three canonical statuses without replacing any, and the projection onto GitHub stays `scripts/github-sync`'s. Nothing here writes anything, which is what makes a user-authored GitHub value safe from it.",
271        stability: Stability::BehaviorallyVerified,
272        capabilities: [
273            capability! {
274                id: "devtask.issue",
275                title: "One issue as an executable development task",
276                description: "Identity, title, intent, status, milestone, acceptance criteria, dependencies, blockers, the commits its own evidence names and the commits git found, its branches, the sessions that worked on it, its readiness and everything wrong with its records — each field carrying whether a person authored it (`explicit`), a machine worked it out by a rule that cannot be wrong (`derived`), a machine worked it out by a rule that can (`inferred`, with the rule stated), or it is not available at all (`unknown`, with the reason). A key the record does not carry is `unknown`, never an empty string: `objective: \"\"` and a record with no objective are the same string and different facts. An id the model does not declare is answered, not refused. `git: false` answers from the records alone, which is what a comparison across machines wants.",
277                input: DevTaskInput,
278                output: DevTask,
279                stability: Stability::BehaviorallyVerified,
280                exposure: Exposure {
281                    mcp: mcp("majordomus_devtask"),
282                    http: get("/api/v1/devtask/issue"),
283                    cli: Some(CliExposure { path: vec!["devtask".into(), "issue".into()] }),
284                },
285                tags: ["devtask", "plan", "issue", "provenance", "readiness", "git"],
286                cache: CachePolicy::Disabled,
287                handler: devtask_issue,
288            },
289            capability! {
290                id: "devtask.milestone",
291                title: "One milestone as an executable dependency graph",
292                description: "Every issue of the milestone as a node with its readiness, its wave and what waits on it; every dependency edge with at least one end inside, the crossing ones marked; the issues partitioned into ready, blocked, waiting, active, review, completion-blocked, complete and cancelled; the critical blockers ordered by how much unfinished work each holds back; the startable work partitioned into subsets that may genuinely run at the same time — same wave, each parallel-safe, no two sharing a scope path — with each serialisation naming the path that caused it; every dependency cycle as its strongly connected component; and every finding about the milestone or its issues. A pure function of the canonical records: no git, no clock, no network, every list in canonical order, so two runs on two machines produce the same bytes. A work surface reading this derives nothing itself.",
293                input: DevMilestoneInput,
294                output: MilestoneGraph,
295                stability: Stability::BehaviorallyVerified,
296                exposure: Exposure {
297                    mcp: mcp("majordomus_devtask_milestone"),
298                    http: get("/api/v1/devtask/milestone"),
299                    cli: Some(CliExposure { path: vec!["devtask".into(), "milestone".into()] }),
300                },
301                tags: ["devtask", "plan", "milestone", "graph", "readiness", "provenance"],
302                cache: CachePolicy::Disabled,
303                handler: devtask_milestone,
304            },
305        ],
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::devtask::{FieldProvenance, TaskReadiness};
313
314    /// The declaration is the only place these names exist; every projection derives from
315    /// it. This is the assertion a refactor that dropped an exposure would fail.
316    #[test]
317    fn the_declaration_yields_the_projections_it_claims() {
318        let m = module();
319        assert_eq!(m.id.as_str(), "devtask");
320        let expected: &[(&str, &str, &str, &[&str])] = &[
321            (
322                "devtask.issue",
323                "majordomus_devtask",
324                "/api/v1/devtask/issue",
325                &["devtask", "issue"],
326            ),
327            (
328                "devtask.milestone",
329                "majordomus_devtask_milestone",
330                "/api/v1/devtask/milestone",
331                &["devtask", "milestone"],
332            ),
333        ];
334        let ids: Vec<&str> = m
335            .capabilities
336            .iter()
337            .map(|e| e.capability.id.as_str())
338            .collect();
339        let want: Vec<&str> = expected.iter().map(|(id, _, _, _)| *id).collect();
340        assert_eq!(ids, want);
341        for (executable, (id, tool, path, cli)) in m.capabilities.iter().zip(expected) {
342            let exposure = &executable.capability.exposure;
343            assert_eq!(
344                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
345                Some(*tool),
346                "{id} lost or renamed its MCP tool"
347            );
348            assert_eq!(
349                exposure.http.as_ref().map(|h| h.path.as_str()),
350                Some(*path),
351                "{id} lost or renamed its HTTP route"
352            );
353            assert_eq!(
354                exposure.cli.as_ref().map(|c| c.path.as_slice()),
355                Some(
356                    cli.iter()
357                        .map(|s| (*s).to_string())
358                        .collect::<Vec<String>>()
359                        .as_slice()
360                ),
361                "{id} lost or renamed its command"
362            );
363        }
364    }
365
366    /// Every capability here is a query. A development task is derived; one that wrote
367    /// anything would be the second store of truth this whole module exists to avoid — and
368    /// the external half in particular must never be writable from a reader.
369    #[test]
370    fn every_capability_is_a_query_and_nothing_caches() {
371        for e in module().capabilities {
372            assert!(
373                e.capability.kind.is_read_only(),
374                "{} writes",
375                e.capability.id
376            );
377            assert!(
378                !e.capability.cache.is_enabled(),
379                "{} caches: the records and the history change outside this process",
380                e.capability.id
381            );
382        }
383    }
384
385    /// The one property that makes this a composition rather than a parallel registry: for
386    /// every issue of this repository, the task's status *is* the plan's status, and its
387    /// readiness names that same status back.
388    #[test]
389    fn the_status_never_disagrees_with_the_plan() {
390        let repo = crate::synthetic::SyntheticRepository::small().expect("synthetic repository");
391        let index = repo.index().expect("index");
392        let plan = Plan::build(&index);
393        for issue in &plan.issues {
394            let record = record(&index, ISSUE, &issue.id)
395                .map(|(path, metadata)| RecordRef { path, metadata });
396            let model = DevTaskModel {
397                plan: &plan,
398                record,
399                trace: None,
400                sessions: Vec::new(),
401                repository: repository_of(&plan),
402            };
403            let task = DevTask::build(&model, &issue.id);
404            assert_eq!(
405                task.position.status.text(),
406                issue.status,
407                "{} disagrees with the plan",
408                issue.id
409            );
410            assert_eq!(
411                task.readiness.canonical_status, issue.status,
412                "{} readiness names a status the plan did not derive",
413                issue.id
414            );
415            assert_eq!(task.position.status.provenance, FieldProvenance::Derived);
416            assert!(task.is_consistent());
417        }
418    }
419
420    /// An id nothing declares is answered rather than refused, and the answer cannot be
421    /// mistaken for an issue nobody has started.
422    #[test]
423    fn an_id_the_model_does_not_have_is_answered_with_undeclared() {
424        let repo = crate::synthetic::SyntheticRepository::small().expect("synthetic repository");
425        let index = repo.index().expect("index");
426        let plan = Plan::build(&index);
427        let model = DevTaskModel {
428            plan: &plan,
429            record: None,
430            trace: None,
431            sessions: Vec::new(),
432            repository: repository_of(&plan),
433        };
434        let task = DevTask::build(&model, "I9999");
435        assert!(!task.declared);
436        assert_eq!(task.readiness.state, TaskReadiness::Undeclared);
437        assert!(!task.readiness.startable);
438    }
439
440    /// The milestone graph partitions its own nodes, whatever the repository holds: an
441    /// issue is in exactly one readiness bucket and every bucket names a node.
442    #[test]
443    fn every_milestone_of_this_repository_is_partitioned() {
444        let repo = crate::synthetic::SyntheticRepository::small().expect("synthetic repository");
445        let index = repo.index().expect("index");
446        let plan = Plan::build(&index);
447        for m in &plan.milestones {
448            let record = record(&index, MILESTONE, &m.id)
449                .map(|(path, metadata)| RecordRef { path, metadata });
450            let g = MilestoneGraph::build(&plan, &m.id, record);
451            assert!(g.is_partitioned(), "{} is not partitioned", m.id);
452            assert!(g.is_consistent(), "{} has an inconsistent field", m.id);
453            assert_eq!(
454                g.status.text(),
455                m.status,
456                "{} disagrees with the plan",
457                m.id
458            );
459        }
460    }
461}