1use 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
22pub const PROJECTION_ADAPTER: &str = "scripts/github-sync";
30
31pub const RECONCILE_WITH: &[&str] = &["scripts/github-sync --check", "scripts/traceability"];
33
34#[derive(Debug)]
36pub struct DevTaskModel<'a> {
37 pub plan: &'a Plan,
39 pub record: Option<RecordRef<'a>>,
41 pub trace: Option<&'a IssueTrace>,
45 pub sessions: Vec<SessionRef>,
49 pub repository: Option<&'a str>,
51}
52
53#[derive(Debug, Clone, Copy)]
55pub struct RecordRef<'a> {
56 pub path: &'a str,
58 pub metadata: &'a Value,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
80pub struct SessionRef {
81 pub session_id: String,
83 pub branch: String,
85 pub created_at: String,
87 pub path: String,
89 pub declares_issue: bool,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100pub struct DevTaskDeclaration {
101 pub title: AttestedText,
103 pub objective: AttestedText,
105 pub why: AttestedText,
107 pub current_state: AttestedText,
109 pub desired_state: AttestedText,
111 pub completion: AttestedText,
114 pub milestone: AttestedText,
116 pub priority: AttestedText,
118 pub profile: AttestedText,
120 pub parallel_safe: AttestedText,
122 pub owner: AttestedText,
124 pub risk: AttestedText,
126 pub slug: AttestedText,
128 pub cancelled: AttestedText,
130 pub created_at: AttestedText,
132 pub updated_at: AttestedText,
134 pub started_at: AttestedText,
136 pub verified_at: AttestedText,
138 pub completed_at: AttestedText,
140 pub scope: AttestedList,
142 pub non_scope: AttestedList,
144 pub depends_on: AttestedList,
147 pub acceptance_criteria: AttestedList,
149 pub validation: AttestedList,
151 pub evidence_required: AttestedList,
153 pub evidence_present: AttestedList,
155 pub evidence_commits: AttestedList,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164pub struct DevTaskPosition {
165 pub status: AttestedText,
167 pub wave: AttestedCount,
170 pub blocked_by: AttestedList,
172 pub dependents: AttestedList,
174 pub transitive_dependents: AttestedCount,
177 pub milestone_status: AttestedText,
179 pub milestone_rank: AttestedCount,
181 pub milestone_blocked_by: AttestedList,
183 pub evidence_have: AttestedCount,
185 pub evidence_need: AttestedCount,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
193pub struct DevTaskExecution {
194 pub git_consulted: bool,
198 pub branches: AttestedList,
200 pub merged_branches: AttestedList,
202 pub commits: AttestedCount,
204 pub trunk: AttestedText,
206 pub commits_complete: AttestedText,
210 pub sessions: AttestedList,
213 pub sessions_by_branch: AttestedList,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223pub struct DevTaskSynchronisation {
224 pub adapter: String,
226 pub repository: AttestedText,
228 pub state: AttestedText,
231 pub external_id: AttestedText,
234 pub reconcile_with: Vec<String>,
236 pub writable: bool,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243pub struct ReadinessVerdict {
244 pub state: TaskReadiness,
246 pub canonical_status: String,
248 pub reason: String,
250 pub startable: bool,
252 pub terminal: bool,
254 pub blockers: Vec<Blocker>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
261pub struct DevTaskDiagnostic {
262 pub level: String,
264 pub code: String,
266 pub message: String,
268 pub reproduce: String,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
277pub struct DevTask {
278 pub issue: String,
280 pub declared: bool,
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub record: Option<String>,
287 pub declaration: DevTaskDeclaration,
289 pub position: DevTaskPosition,
291 pub execution: DevTaskExecution,
293 pub synchronisation: DevTaskSynchronisation,
295 pub readiness: ReadinessVerdict,
297 pub diagnostics: Vec<DevTaskDiagnostic>,
299 pub attestation: AttestationTally,
301}
302
303fn 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
314fn 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
327fn 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 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
348fn 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
356fn 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 const PLAN: &'static str = "plan.model";
380 const TRACE: &'static str = "trace.issue";
382
383 #[allow(clippy::too_many_lines)] 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 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 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 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 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 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 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 #[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 #[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 assert_eq!(
994 t.declaration.depends_on.provenance,
995 FieldProvenance::Explicit
996 );
997 assert!(t.declaration.depends_on.is_empty());
998 assert_eq!(t.declaration.objective.provenance, FieldProvenance::Unknown);
1000 assert!(t.declaration.objective.reason.is_some());
1001 }
1002
1003 #[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 #[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 #[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 assert_eq!(t.execution.sessions.provenance, FieldProvenance::Derived);
1092 assert_eq!(t.execution.sessions.values, ["s-1"]);
1093 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 assert!(t.execution.sessions.values.iter().all(|s| !t
1105 .execution
1106 .sessions_by_branch
1107 .values
1108 .contains(s)));
1109 }
1110
1111 #[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 assert_eq!(
1136 t.synchronisation.repository.provenance,
1137 FieldProvenance::Explicit
1138 );
1139 }
1140
1141 #[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 #[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 #[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}