agentic-devtools 0.2.332__py3-none-any.whl → 0.2.334__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. agentic_devtools/_version.py +2 -2
  2. agentic_devtools/adapters/__init__.py +8 -0
  3. agentic_devtools/adapters/idempotency_query_provider.py +64 -0
  4. agentic_devtools/adapters/issue_provider.py +65 -0
  5. agentic_devtools/adapters/jira_provider.py +140 -75
  6. agentic_devtools/adapters/operation_plan.py +111 -0
  7. agentic_devtools/adapters/orchestration_key.py +39 -15
  8. agentic_devtools/adapters/plan_manifest.py +437 -0
  9. agentic_devtools/orchestration/graph_builder.py +15 -5
  10. agentic_devtools/orchestration/nodes/__init__.py +31 -0
  11. agentic_devtools/orchestration/nodes/_helpers.py +261 -0
  12. agentic_devtools/orchestration/nodes/checklist_creation.py +139 -0
  13. agentic_devtools/orchestration/nodes/commit.py +169 -0
  14. agentic_devtools/orchestration/nodes/completion.py +163 -0
  15. agentic_devtools/orchestration/nodes/implementation.py +395 -0
  16. agentic_devtools/orchestration/nodes/implementation_review.py +126 -0
  17. agentic_devtools/orchestration/nodes/initiate.py +136 -0
  18. agentic_devtools/orchestration/nodes/planning.py +221 -0
  19. agentic_devtools/orchestration/nodes/pull_request.py +256 -0
  20. agentic_devtools/orchestration/nodes/retrieve.py +198 -0
  21. agentic_devtools/orchestration/nodes/setup.py +123 -0
  22. agentic_devtools/orchestration/nodes/verification.py +96 -0
  23. agentic_devtools/orchestration/pilot_workflow.py +53 -21
  24. agentic_devtools/orchestration/runner.py +62 -9
  25. agentic_devtools/orchestration/state_schema.py +24 -0
  26. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/METADATA +1 -1
  27. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/RECORD +30 -14
  28. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/WHEEL +0 -0
  29. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/entry_points.txt +0 -0
  30. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/licenses/LICENSE +0 -0
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.332'
22
- __version_tuple__ = version_tuple = (0, 2, 332)
21
+ __version__ = version = '0.2.334'
22
+ __version_tuple__ = version_tuple = (0, 2, 334)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -31,6 +31,7 @@ from agentic_devtools.adapters.base import (
31
31
  from agentic_devtools.adapters.exceptions import AdapterError, AdapterValidationError
32
32
  from agentic_devtools.adapters.factory import get_issue_provider
33
33
  from agentic_devtools.adapters.github_adapter import GitHubIssuesAdapter
34
+ from agentic_devtools.adapters.idempotency_query_provider import IdempotencyQueryProvider
34
35
  from agentic_devtools.adapters.issue_provider import (
35
36
  VALID_ISSUE_TYPES,
36
37
  InMemoryIssueProvider,
@@ -50,7 +51,9 @@ from agentic_devtools.adapters.issue_type_mapping import (
50
51
  )
51
52
  from agentic_devtools.adapters.jira_adapter import JiraAdapter
52
53
  from agentic_devtools.adapters.markdown_adapter import MarkdownAdapter
54
+ from agentic_devtools.adapters.operation_plan import OperationDescriptor, OperationPlan
53
55
  from agentic_devtools.adapters.orchestration_key import generate_orchestration_key
56
+ from agentic_devtools.adapters.plan_manifest import execute_manifest, plan_manifest
54
57
  from agentic_devtools.config import load_platform_config
55
58
  from agentic_devtools.tools.jira import JiraConfig
56
59
 
@@ -61,6 +64,7 @@ __all__ = [
61
64
  "CommentResult",
62
65
  "GitHubIssuesAdapter",
63
66
  "GitHubMappingResult",
67
+ "IdempotencyQueryProvider",
64
68
  "InMemoryIssueProvider",
65
69
  "IssueAdapter",
66
70
  "IssueDetail",
@@ -75,11 +79,14 @@ __all__ = [
75
79
  "JiraMappingResult",
76
80
  "MarkdownAdapter",
77
81
  "NormalizedIssue",
82
+ "OperationDescriptor",
83
+ "OperationPlan",
78
84
  "PropertySchema",
79
85
  "ProviderIssueResult",
80
86
  "ProviderLinkResult",
81
87
  "TypeMapping",
82
88
  "VALID_ISSUE_TYPES",
89
+ "execute_manifest",
83
90
  "generate_orchestration_key",
84
91
  "get_adapter",
85
92
  "get_issue_provider",
@@ -87,6 +94,7 @@ __all__ = [
87
94
  "load_jira_type_mapping",
88
95
  "map_issue_type_to_github_labels",
89
96
  "map_issue_type_to_jira",
97
+ "plan_manifest",
90
98
  ]
91
99
 
92
100
 
@@ -0,0 +1,64 @@
1
+ """IdempotencyQueryProvider protocol for adapter-level existence queries.
2
+
3
+ Separated from ``IssueProvider`` (which has exactly 8 methods) to avoid
4
+ a breaking change to the mutation protocol. Used by the orchestrator
5
+ when ``dry_run=True`` and ``check_existing=True``.
6
+
7
+ All ``*_provider_id`` parameters accept provider-native string identifiers
8
+ (e.g., ``"42"`` for GitHub, ``"PROJ-123"`` for Jira).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ from agentic_devtools.adapters.issue_provider import ProviderIssueResult, ProviderLinkResult
16
+
17
+
18
+ @runtime_checkable
19
+ class IdempotencyQueryProvider(Protocol):
20
+ """Companion protocol for adapter-level idempotency queries.
21
+
22
+ Separated from IssueProvider (which has exactly 8 methods) to avoid
23
+ a breaking change to the mutation protocol. Used by the orchestrator
24
+ when dry_run=True and check_existing=True.
25
+
26
+ All *_provider_id parameters accept provider-native string identifiers
27
+ (e.g., "42" for GitHub, "PROJ-123" for Jira).
28
+ """
29
+
30
+ def find_existing_issue(
31
+ self,
32
+ orchestration_key: str,
33
+ ) -> ProviderIssueResult | None:
34
+ """Find an issue by its embedded orchestration key.
35
+
36
+ Returns ProviderIssueResult with status="existing" if found,
37
+ None if not found. Raises ValueError if multiple matches found
38
+ (ambiguous state — FR-008).
39
+ """
40
+ ... # pragma: no cover
41
+
42
+ def find_existing_link(
43
+ self,
44
+ parent_provider_id: str,
45
+ child_provider_id: str,
46
+ ) -> ProviderLinkResult | None:
47
+ """Check if a parent-child link already exists.
48
+
49
+ Returns ProviderLinkResult with status="already-linked" if found,
50
+ None if not found.
51
+ """
52
+ ... # pragma: no cover
53
+
54
+ def find_existing_dependency(
55
+ self,
56
+ issue_provider_id: str,
57
+ blocked_by_provider_id: str,
58
+ ) -> ProviderLinkResult | None:
59
+ """Check if a blocking dependency already exists.
60
+
61
+ Returns ProviderLinkResult with status="already-linked" if found,
62
+ None if not found.
63
+ """
64
+ ... # pragma: no cover
@@ -703,3 +703,68 @@ class InMemoryIssueProvider:
703
703
  return f"#{identifier}"
704
704
  return identifier
705
705
  return identifier
706
+
707
+ # -- IdempotencyQueryProvider methods --
708
+
709
+ def find_existing_issue(self, orchestration_key: str) -> ProviderIssueResult | None:
710
+ """Find issue by orchestration key embedded in body.
711
+
712
+ Searches all stored issues for a body containing the given key.
713
+ Also checks the idempotency_keys mapping.
714
+
715
+ Raises:
716
+ ValueError: If multiple issues match (ambiguous state — FR-008).
717
+ """
718
+ from agentic_devtools.adapters.orchestration_key import extract_orchestration_key
719
+
720
+ matches: list[str] = []
721
+ for identifier, data in self._issues.items():
722
+ body_val = data.get("body")
723
+ key = extract_orchestration_key(body_val if isinstance(body_val, str) else "")
724
+ if key == orchestration_key:
725
+ matches.append(identifier)
726
+ if len(matches) > 1:
727
+ raise ValueError(f"Ambiguous: key {orchestration_key!r} matched {len(matches)} issues: {matches}")
728
+ if not matches:
729
+ # Check idempotency_keys mapping as fallback
730
+ ident = self._idempotency_keys.get(orchestration_key)
731
+ if ident is not None:
732
+ matches.append(ident)
733
+ if not matches:
734
+ return None
735
+ ident = matches[0]
736
+ return ProviderIssueResult(
737
+ identifier=ident,
738
+ url=f"https://fake.test/issues/{ident}",
739
+ status="existing",
740
+ )
741
+
742
+ def find_existing_link(self, parent_provider_id: str, child_provider_id: str) -> ProviderLinkResult | None:
743
+ """Check if a parent-child link already exists.
744
+
745
+ Returns ProviderLinkResult with status="already-linked" if found,
746
+ None if not found.
747
+ """
748
+ if (parent_provider_id, child_provider_id) in self._parent_child:
749
+ return ProviderLinkResult(
750
+ source_id=parent_provider_id,
751
+ target_id=child_provider_id,
752
+ status="already-linked",
753
+ )
754
+ return None
755
+
756
+ def find_existing_dependency(
757
+ self, issue_provider_id: str, blocked_by_provider_id: str
758
+ ) -> ProviderLinkResult | None:
759
+ """Check if a blocking dependency already exists.
760
+
761
+ Returns ProviderLinkResult with status="already-linked" if found,
762
+ None if not found.
763
+ """
764
+ if (issue_provider_id, blocked_by_provider_id) in self._blocked_by:
765
+ return ProviderLinkResult(
766
+ source_id=blocked_by_provider_id,
767
+ target_id=issue_provider_id,
768
+ status="already-linked",
769
+ )
770
+ return None
@@ -550,33 +550,12 @@ class JiraProvider:
550
550
  )
551
551
 
552
552
  # Resolve the epic-link custom field once (cached after first call).
553
- epic_field = self._get_epic_link_field()
554
-
555
- # Idempotency check: GET child issue to see if the parent is already set,
556
- # either via the epic-link field or the standard parent field.
557
- get_url = self._api_url(f"/issue/{child_id}?fields={epic_field},parent")
558
- get_resp = self._request("GET", get_url)
559
- if get_resp.status_code == 404:
560
- raise ValueError(f"Issue '{child_id}' not found.")
561
- get_resp.raise_for_status()
562
- get_data = get_resp.json()
563
- if isinstance(get_data, dict):
564
- _fields_raw = get_data.get("fields")
565
- get_fields = _fields_raw if isinstance(_fields_raw, dict) else {}
566
- if get_fields.get(epic_field) == parent_id:
567
- return ProviderLinkResult(
568
- source_id=parent_id,
569
- target_id=child_id,
570
- status="already-linked",
571
- )
572
- parent_field_val = get_fields.get("parent")
573
- if isinstance(parent_field_val, dict) and parent_field_val.get("key") == parent_id:
574
- return ProviderLinkResult(
575
- source_id=parent_id,
576
- target_id=child_id,
577
- status="already-linked",
578
- )
553
+ # Idempotency check via find_existing_link (FR-003)
554
+ existing = self.find_existing_link(parent_id, child_id)
555
+ if existing is not None:
556
+ return existing
579
557
 
558
+ epic_field = self._get_epic_link_field()
580
559
  url = self._api_url(f"/issue/{child_id}")
581
560
  payload = {"fields": {epic_field: parent_id}}
582
561
  epic_resp = self._request("PUT", url, json=payload)
@@ -646,35 +625,10 @@ class JiraProvider:
646
625
  )
647
626
 
648
627
  url = self._api_url("/issueLink")
649
- # Check for an existing "Blocks" link before creating (idempotency).
650
- get_url = self._api_url(f"/issue/{issue_id}?fields=issuelinks")
651
- get_resp = self._request("GET", get_url)
652
- if get_resp.status_code == 404:
653
- raise ValueError(f"Cannot check existing links because issue '{issue_id}' was not found.")
654
- get_resp.raise_for_status()
655
- get_data = get_resp.json()
656
- if isinstance(get_data, dict):
657
- _fields_raw = get_data.get("fields")
658
- fields = _fields_raw if isinstance(_fields_raw, dict) else {}
659
- existing_links = fields.get("issuelinks") or []
660
- for link in existing_links:
661
- if not isinstance(link, dict):
662
- continue
663
- link_type = link.get("type")
664
- if not isinstance(link_type, dict):
665
- continue
666
- if (link_type.get("name") or "").lower() == "blocks":
667
- _raw_outward = link.get("outwardIssue")
668
- outward = _raw_outward if isinstance(_raw_outward, dict) else {}
669
- # Only the outward direction means "blocked_by_id blocks issue_id".
670
- # inwardIssue=blocked_by_id would mean the opposite edge (issue_id
671
- # blocks blocked_by_id) and must NOT short-circuit idempotency.
672
- if outward.get("key") == blocked_by_id:
673
- return ProviderLinkResult(
674
- source_id=blocked_by_id,
675
- target_id=issue_id,
676
- status="already-linked",
677
- )
628
+ # Idempotency check via find_existing_dependency (FR-004)
629
+ existing = self.find_existing_dependency(issue_id, blocked_by_id)
630
+ if existing is not None:
631
+ return existing
678
632
 
679
633
  payload = {
680
634
  "type": {"name": "Blocks"},
@@ -811,29 +765,140 @@ class JiraProvider:
811
765
  return self._effective_type_map[type_lower]
812
766
 
813
767
  def _find_by_orchestration_key(self, orch_key: str) -> ProviderIssueResult | None:
814
- """Search for an existing issue via JQL with orchestration key."""
815
- jql = f'project = "{self._project_key}" AND description ~ "agdt-orch-key:{orch_key}"'
768
+ """Search for an existing issue via JQL with orchestration key.
769
+
770
+ Internal helper that delegates to :meth:`find_existing_issue`.
771
+ """
772
+ return self.find_existing_issue(orch_key)
773
+
774
+ # ------------------------------------------------------------------
775
+ # IdempotencyQueryProvider methods
776
+ # ------------------------------------------------------------------
777
+
778
+ def find_existing_issue(self, orchestration_key: str) -> ProviderIssueResult | None:
779
+ """Find an issue by its embedded orchestration key via JQL search.
780
+
781
+ Raises ValueError if multiple issues match (ambiguous state — FR-008).
782
+ Propagates network/provider errors (FR-009).
783
+ """
784
+ jql = f'project = "{self._project_key}" AND description ~ "agdt-orch-key:{orchestration_key}"'
816
785
  url = self._api_url("/search")
817
- params = {"jql": jql, "maxResults": "1", "fields": "key,summary"}
786
+ params = {"jql": jql, "maxResults": "10", "fields": "key,summary"}
818
787
 
819
- try:
820
- resp = self._request("GET", url, params=params)
821
- resp.raise_for_status()
822
- data = resp.json()
823
- issues = data.get("issues", [])
824
- if issues:
825
- issue = issues[0]
826
- issue_key = issue.get("key", "")
827
- return ProviderIssueResult(
828
- identifier=issue_key,
829
- url=f"{self._base_url}/browse/{issue_key}",
830
- status="existing",
831
- metadata={"id": issue.get("id", "")},
788
+ resp = self._request("GET", url, params=params)
789
+ resp.raise_for_status()
790
+ data = resp.json()
791
+ if not isinstance(data, dict):
792
+ raise ValueError(f"find_existing_issue: expected a JSON object from Jira search, got {type(data).__name__}")
793
+ issues = data.get("issues", [])
794
+ if not isinstance(issues, list):
795
+ raise ValueError(f"find_existing_issue: expected 'issues' to be a list, got {type(issues).__name__}")
796
+ if len(issues) > 1:
797
+ ids = [issue.get("key", "") if isinstance(issue, dict) else repr(issue) for issue in issues]
798
+ raise ValueError(
799
+ f"Ambiguous state: orchestration key {orchestration_key!r} "
800
+ f"matched {len(issues)} issues: {ids}. "
801
+ f"Resolve manually before re-running."
802
+ )
803
+ if issues:
804
+ issue = issues[0]
805
+ if not isinstance(issue, dict):
806
+ raise ValueError(
807
+ f"find_existing_issue: expected issue entry to be a JSON object, got {type(issue).__name__}"
808
+ )
809
+ issue_key = issue.get("key", "")
810
+ if not isinstance(issue_key, str) or not issue_key.strip():
811
+ raise ValueError(
812
+ f"find_existing_issue: expected issue 'key' to be a non-empty string, got {issue_key!r}"
832
813
  )
833
- except TransientError:
834
- raise
835
- except Exception:
836
- pass
814
+ return ProviderIssueResult(
815
+ identifier=issue_key,
816
+ url=f"{self._base_url}/browse/{issue_key}",
817
+ status="existing",
818
+ metadata={"id": issue.get("id", "")},
819
+ )
820
+ return None
821
+
822
+ def find_existing_link(self, parent_provider_id: str, child_provider_id: str) -> ProviderLinkResult | None:
823
+ """Check if a parent-child link already exists (FR-003).
824
+
825
+ GETs the child issue and inspects epic-link and parent fields.
826
+ Returns ProviderLinkResult with status="already-linked" if found,
827
+ None if not found. Propagates network errors (FR-009).
828
+ """
829
+ epic_field = self._get_epic_link_field()
830
+ get_url = self._api_url(f"/issue/{child_provider_id}?fields={epic_field},parent")
831
+ get_resp = self._request("GET", get_url)
832
+ if get_resp.status_code == 404:
833
+ return None
834
+ get_resp.raise_for_status()
835
+ get_data = get_resp.json()
836
+ if not isinstance(get_data, dict):
837
+ raise ValueError(f"find_existing_link: expected a JSON object from Jira, got {type(get_data).__name__}")
838
+ _fields_raw = get_data.get("fields")
839
+ get_fields = _fields_raw if isinstance(_fields_raw, dict) else {}
840
+ if get_fields.get(epic_field) == parent_provider_id:
841
+ return ProviderLinkResult(
842
+ source_id=parent_provider_id,
843
+ target_id=child_provider_id,
844
+ status="already-linked",
845
+ )
846
+ parent_field_val = get_fields.get("parent")
847
+ if isinstance(parent_field_val, dict) and parent_field_val.get("key") == parent_provider_id:
848
+ return ProviderLinkResult(
849
+ source_id=parent_provider_id,
850
+ target_id=child_provider_id,
851
+ status="already-linked",
852
+ )
853
+ return None
854
+
855
+ def find_existing_dependency(
856
+ self, issue_provider_id: str, blocked_by_provider_id: str
857
+ ) -> ProviderLinkResult | None:
858
+ """Check if a blocking dependency already exists (FR-004).
859
+
860
+ GETs the issue's links and checks for a "Blocks" outward link
861
+ to blocked_by_provider_id. Returns ProviderLinkResult with
862
+ status="already-linked" if found, None if not found.
863
+ Propagates network errors (FR-009).
864
+ """
865
+ get_url = self._api_url(f"/issue/{issue_provider_id}?fields=issuelinks")
866
+ get_resp = self._request("GET", get_url)
867
+ if get_resp.status_code == 404:
868
+ return None
869
+ get_resp.raise_for_status()
870
+ get_data = get_resp.json()
871
+ if not isinstance(get_data, dict):
872
+ raise ValueError(
873
+ f"find_existing_dependency: expected a JSON object from Jira, got {type(get_data).__name__}"
874
+ )
875
+ _fields_raw = get_data.get("fields")
876
+ fields = _fields_raw if isinstance(_fields_raw, dict) else {}
877
+ existing_links_raw = fields.get("issuelinks")
878
+ if existing_links_raw is None:
879
+ existing_links: list[Any] = []
880
+ elif isinstance(existing_links_raw, list):
881
+ existing_links = existing_links_raw
882
+ else:
883
+ raise ValueError(
884
+ "find_existing_dependency: expected fields.issuelinks to be a list when present, "
885
+ f"got {type(existing_links_raw).__name__}"
886
+ )
887
+ for link in existing_links:
888
+ if not isinstance(link, dict):
889
+ continue
890
+ link_type = link.get("type")
891
+ if not isinstance(link_type, dict):
892
+ continue
893
+ if (link_type.get("name") or "").lower() == "blocks":
894
+ _raw_outward = link.get("outwardIssue")
895
+ outward = _raw_outward if isinstance(_raw_outward, dict) else {}
896
+ if outward.get("key") == blocked_by_provider_id:
897
+ return ProviderLinkResult(
898
+ source_id=blocked_by_provider_id,
899
+ target_id=issue_provider_id,
900
+ status="already-linked",
901
+ )
837
902
  return None
838
903
 
839
904
  # ------------------------------------------------------------------
@@ -0,0 +1,111 @@
1
+ """Operation plan data model for dry-run and execution tracking (FR-006).
2
+
3
+ Defines ``OperationDescriptor`` (a single planned/executed operation) and
4
+ ``OperationPlan`` (an ordered collection of descriptors). Both are frozen
5
+ dataclasses — strictly in-memory, not designed for disk persistence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from agentic_devtools.adapters.issue_provider import ProviderIssueResult, ProviderLinkResult
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class OperationDescriptor:
19
+ """A single planned or executed operation.
20
+
21
+ Attributes:
22
+ operation_type: One of "create_issue", "link_subissue", or "add_blocked_by".
23
+ orchestration_key: 64-char hex SHA-256 key for this operation.
24
+ refs: Manifest refs used in key derivation, in canonical order.
25
+ status: Operation outcome — "dry-run", "existing", "already-linked",
26
+ "created", or "linked".
27
+ provider_params: Provider-facing operation inputs captured in the
28
+ plan (for example title/issue_type/labels, plus ref-based
29
+ relationship fields such as parent_ref/child_ref/blocked_by_ref
30
+ used during planning before provider identifiers are resolved).
31
+ result: The adapter result object, or None for planning-only dry-run.
32
+ """
33
+
34
+ operation_type: str
35
+ orchestration_key: str
36
+ refs: tuple[str, ...]
37
+ status: str
38
+ provider_params: dict[str, Any] = field(default_factory=dict)
39
+ result: ProviderIssueResult | ProviderLinkResult | None = None
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ """Return a JSON-serializable dictionary representation."""
43
+ result_dict: dict[str, Any] | None = None
44
+ if self.result is not None:
45
+ result_dict = self.result.to_dict()
46
+ return {
47
+ "operation_type": self.operation_type,
48
+ "orchestration_key": self.orchestration_key,
49
+ "refs": list(self.refs),
50
+ "status": self.status,
51
+ "provider_params": copy.deepcopy(self.provider_params),
52
+ "result": result_dict,
53
+ }
54
+
55
+ @property
56
+ def is_dry_run(self) -> bool:
57
+ """Return True if this descriptor represents a dry-run operation."""
58
+ return self.status == "dry-run"
59
+
60
+ @property
61
+ def is_existing(self) -> bool:
62
+ """Return True if this descriptor represents an already-existing entity."""
63
+ return self.status in ("existing", "already-linked")
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class OperationPlan:
68
+ """An ordered collection of operation descriptors.
69
+
70
+ Strictly in-memory — not designed for serialization to disk for
71
+ cross-run resumption. The orchestrator re-derives the plan from the
72
+ manifest on each invocation.
73
+
74
+ Attributes:
75
+ operations: Dependency-safe ordered tuple of descriptors.
76
+ dry_run: Whether this plan was generated in dry-run mode.
77
+ check_existing: Whether existence checks were performed.
78
+ """
79
+
80
+ operations: tuple[OperationDescriptor, ...]
81
+ dry_run: bool
82
+ check_existing: bool
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ """Return a JSON-serializable dictionary representation."""
86
+ return {
87
+ "operations": [op.to_dict() for op in self.operations],
88
+ "dry_run": self.dry_run,
89
+ "check_existing": self.check_existing,
90
+ "summary": {
91
+ "total": len(self.operations),
92
+ "creates": len(self.create_operations),
93
+ "links": len(self.link_operations),
94
+ "dependencies": len(self.dependency_operations),
95
+ },
96
+ }
97
+
98
+ @property
99
+ def create_operations(self) -> tuple[OperationDescriptor, ...]:
100
+ """Return only create_issue operations."""
101
+ return tuple(op for op in self.operations if op.operation_type == "create_issue")
102
+
103
+ @property
104
+ def link_operations(self) -> tuple[OperationDescriptor, ...]:
105
+ """Return only link_subissue operations."""
106
+ return tuple(op for op in self.operations if op.operation_type == "link_subissue")
107
+
108
+ @property
109
+ def dependency_operations(self) -> tuple[OperationDescriptor, ...]:
110
+ """Return only add_blocked_by operations."""
111
+ return tuple(op for op in self.operations if op.operation_type == "add_blocked_by")
@@ -1,15 +1,23 @@
1
1
  """Orchestration key generation and embedding utilities.
2
2
 
3
- An orchestration key is a SHA-256 hash derived from:
4
- - source_issue_key: The originating issue identifier (e.g., "#2108")
5
- - structural_position: Dot-notation path in the hierarchy (e.g., "epic.features[0]")
6
- - content_hash: Hash of the issue title/body content for uniqueness
3
+ An orchestration key is a deterministic SHA-256 hash derived from:
4
+ - operation_type: The operation name (e.g., "create_issue", "link_subissue")
5
+ - refs: One or more ref values in canonical order matching the IssueProvider
6
+ method signature for the given operation
7
+
8
+ Algorithm: SHA-256 of the UTF-8 encoding of the NUL-separated (``\\x00``)
9
+ concatenation of ``operation_type`` followed by all ref values in signature order.
7
10
 
8
11
  The key is embedded in issue bodies as an HTML comment:
9
12
  ``<!-- agdt-orch-key:SHA256_HEX -->``
10
13
 
11
14
  This enables idempotent find-before-create semantics — the provider can search
12
15
  for an existing issue bearing the same orchestration key before creating a new one.
16
+
17
+ Compatibility: The pre-#2114 algorithm (colon-separated ``source:position:content_hash``)
18
+ is explicitly **not** backward-compatible. Orchestration keys generated with the prior
19
+ algorithm will not match keys from this implementation. The embedding format
20
+ (``<!-- agdt-orch-key:<64hex> -->``) remains unchanged (NFR-002).
13
21
  """
14
22
 
15
23
  from __future__ import annotations
@@ -21,22 +29,38 @@ import re
21
29
  _ORCH_KEY_PATTERN = re.compile(r"<!-- agdt-orch-key:([a-f0-9]{64}) -->", re.IGNORECASE)
22
30
 
23
31
 
24
- def generate_orchestration_key(
25
- source_issue_key: str,
26
- structural_position: str,
27
- content_hash: str,
28
- ) -> str:
29
- """Generate a SHA-256 orchestration key.
32
+ def generate_orchestration_key(operation_type: str, *refs: str) -> str:
33
+ """Generate a deterministic SHA-256 orchestration key.
34
+
35
+ Algorithm: SHA-256 of UTF-8 encoding of the NUL-separated concatenation
36
+ of operation_type followed by all ref values in signature order.
30
37
 
31
38
  Args:
32
- source_issue_key: The originating issue identifier.
33
- structural_position: Dot-notation path in the hierarchy.
34
- content_hash: Hash or fingerprint of the issue content.
39
+ operation_type: The operation name (e.g., "create_issue").
40
+ *refs: Ref values in the canonical order matching the IssueProvider
41
+ method signature for this operation.
35
42
 
36
43
  Returns:
37
- A 64-character lowercase hex SHA-256 hash.
44
+ A 64-character lowercase hex SHA-256 digest.
45
+
46
+ Raises:
47
+ ValueError: If operation_type is not a string, empty/whitespace-only,
48
+ or if operation_type or any ref contains a NUL byte (``\\x00``).
49
+ If any ref is not a string.
38
50
  """
39
- payload = f"{source_issue_key}:{structural_position}:{content_hash}"
51
+ if not isinstance(operation_type, str):
52
+ raise ValueError(f"operation_type must be a string, got {type(operation_type).__name__}")
53
+ if not operation_type or not operation_type.strip():
54
+ raise ValueError("operation_type must be a non-empty string")
55
+ if "\x00" in operation_type:
56
+ raise ValueError("operation_type must not contain NUL bytes")
57
+ for ref in refs:
58
+ if not isinstance(ref, str):
59
+ raise ValueError(f"each ref must be a string, got {type(ref).__name__}")
60
+ if "\x00" in ref:
61
+ raise ValueError("refs must not contain NUL bytes")
62
+
63
+ payload = "\x00".join((operation_type, *refs))
40
64
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()
41
65
 
42
66