1use 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct DevTaskInput {
61 pub issue: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub git: Option<bool>,
70}
71
72impl BenchmarkCases for DevTaskInput {
73 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
74 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)]
99pub struct DevMilestoneInput {
101 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
116fn 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
128fn 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
139fn 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
152fn 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 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
204fn 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
217fn 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
232fn 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
262pub 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 #[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 #[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 #[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 #[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 #[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}