agentic-devtools 0.2.332__py3-none-any.whl → 0.2.333__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.
@@ -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.333'
22
+ __version_tuple__ = version_tuple = (0, 2, 333)
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
 
@@ -0,0 +1,437 @@
1
+ """Manifest planning and execution for dry-run and idempotent issue creation.
2
+
3
+ Provides ``plan_manifest()`` for assembling a dependency-safe ``OperationPlan``
4
+ from a JSON manifest, and ``execute_manifest()`` as a convenience wrapper for
5
+ real execution mode.
6
+
7
+ The manifest describes a tree of issues with parent-child and blocking
8
+ relationships. The orchestrator traverses the manifest in topological order,
9
+ computes orchestration keys for each operation, and either previews (dry-run)
10
+ or executes (real) the operations through an ``IssueProvider``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from agentic_devtools.adapters.cycle_detection import CycleDetectedError, detect_cycles
18
+ from agentic_devtools.adapters.idempotency_query_provider import IdempotencyQueryProvider
19
+ from agentic_devtools.adapters.issue_provider import VALID_ISSUE_TYPES, IssueProvider
20
+ from agentic_devtools.adapters.operation_plan import OperationDescriptor, OperationPlan
21
+ from agentic_devtools.adapters.orchestration_key import embed_orchestration_key, generate_orchestration_key
22
+
23
+
24
+ def plan_manifest(
25
+ manifest: dict[str, Any],
26
+ provider: IssueProvider,
27
+ *,
28
+ dry_run: bool = True,
29
+ check_existing: bool = False,
30
+ query_provider: IdempotencyQueryProvider | None = None,
31
+ ) -> OperationPlan:
32
+ """Assemble a dependency-safe operation plan from a manifest.
33
+
34
+ Args:
35
+ manifest: JSON manifest with "nodes" list. Each node has "ref",
36
+ "title", "body", "issue_type", and optionally "parent_ref"
37
+ and "blocked_by" (list of refs).
38
+ provider: IssueProvider for dry-run param extraction / real execution.
39
+ dry_run: When True, no mutations. When False, execute operations.
40
+ check_existing: When True (dry-run only), query provider for
41
+ existing entities. Requires query_provider.
42
+ query_provider: IdempotencyQueryProvider for existence checks.
43
+ Required when check_existing=True.
44
+
45
+ Returns:
46
+ OperationPlan with ordered OperationDescriptor entries.
47
+
48
+ Raises:
49
+ ValueError: If check_existing=True but query_provider is None.
50
+ ValueError: If check_existing=True but dry_run=False.
51
+ ValueError: If check_existing=True but query_provider does not implement
52
+ the IdempotencyQueryProvider protocol.
53
+ ValueError: If manifest is not a dict, manifest['nodes'] is not a list,
54
+ any node is not a dict, a node is missing a required key
55
+ ('ref', 'title', 'issue_type'), a required key value is not a
56
+ non-empty string after trimming, issue_type is unsupported,
57
+ 'body' is present but is neither a string nor null,
58
+ 'blocked_by' or 'labels' is present but not a list,
59
+ or duplicate refs are detected.
60
+ ValueError: If a circular dependency is detected among manifest nodes.
61
+ ValueError: If a node's parent_ref or blocked_by ref cannot be resolved during real execution.
62
+ Any provider error: Propagated from adapter calls (FR-009).
63
+ """
64
+ if check_existing and query_provider is None:
65
+ raise ValueError("check_existing=True requires a query_provider")
66
+ if check_existing and not dry_run:
67
+ raise ValueError("check_existing=True is only valid with dry_run=True")
68
+ if check_existing and not isinstance(query_provider, IdempotencyQueryProvider):
69
+ raise ValueError(
70
+ f"query_provider must implement the IdempotencyQueryProvider protocol, got {type(query_provider).__name__}"
71
+ )
72
+
73
+ if not isinstance(manifest, dict):
74
+ raise ValueError(f"manifest must be a dict, got {type(manifest).__name__}")
75
+
76
+ nodes = manifest.get("nodes", [])
77
+
78
+ # Validate manifest structure up front for deterministic, actionable errors.
79
+ if not isinstance(nodes, list):
80
+ raise ValueError(f"manifest['nodes'] must be a list, got {type(nodes).__name__}")
81
+ validated_nodes: list[dict[str, Any]] = []
82
+ seen_refs: set[str] = set()
83
+ for i, node in enumerate(nodes):
84
+ if not isinstance(node, dict):
85
+ raise ValueError(f"manifest['nodes'][{i}] must be a dict, got {type(node).__name__}")
86
+ normalized_node = dict(node)
87
+ for key in ("ref", "title", "issue_type"):
88
+ if key not in node:
89
+ raise ValueError(f"manifest['nodes'][{i}] missing required key {key!r}")
90
+ value = node[key]
91
+ if not isinstance(value, str) or not value.strip():
92
+ raise ValueError(
93
+ f"manifest['nodes'][{i}][{key!r}] must be a non-empty string, got {type(value).__name__!r}"
94
+ )
95
+ if key == "issue_type":
96
+ normalized_value = value.strip().lower()
97
+ if normalized_value not in VALID_ISSUE_TYPES:
98
+ raise ValueError(
99
+ f"Unsupported issue_type {normalized_value!r}. Valid types: {sorted(VALID_ISSUE_TYPES)}"
100
+ )
101
+ else:
102
+ normalized_value = value.strip()
103
+ normalized_node[key] = normalized_value
104
+ if "body" in node and node["body"] is not None and not isinstance(node["body"], str):
105
+ raise ValueError(
106
+ f"manifest['nodes'][{i}]['body'] must be a string or null, got {type(node['body']).__name__!r}"
107
+ )
108
+ if "parent_ref" in node:
109
+ parent_ref_val = node["parent_ref"]
110
+ if not isinstance(parent_ref_val, str) or not parent_ref_val.strip():
111
+ raise ValueError(
112
+ f"manifest['nodes'][{i}]['parent_ref'] must be a non-empty string, "
113
+ f"got {type(parent_ref_val).__name__!r}"
114
+ )
115
+ normalized_node["parent_ref"] = parent_ref_val.strip()
116
+ for list_key in ("blocked_by", "labels"):
117
+ if list_key not in node:
118
+ continue
119
+ value = node[list_key]
120
+ if not isinstance(value, list):
121
+ raise ValueError(f"manifest['nodes'][{i}][{list_key!r}] must be a list, got {type(value).__name__!r}")
122
+ normalized_elems: list[str] = []
123
+ for j, elem in enumerate(value):
124
+ if not isinstance(elem, str) or not elem.strip():
125
+ raise ValueError(
126
+ f"manifest['nodes'][{i}][{list_key!r}][{j}] must be a non-empty string, "
127
+ f"got {type(elem).__name__!r}"
128
+ )
129
+ normalized_elems.append(elem.strip())
130
+ normalized_node[list_key] = normalized_elems
131
+ node_ref = normalized_node["ref"]
132
+ if node_ref in seen_refs:
133
+ raise ValueError(f"Duplicate ref {node_ref!r} in manifest['nodes']")
134
+ seen_refs.add(node_ref)
135
+ validated_nodes.append(normalized_node)
136
+
137
+ # Build node lookup and edges for topological sort
138
+ node_map: dict[str, dict[str, Any]] = {}
139
+ for node in validated_nodes:
140
+ node_map[node["ref"]] = node
141
+
142
+ # Determine topological order based on blocked_by relationships
143
+ edges: list[tuple[str, str]] = []
144
+ for node in validated_nodes:
145
+ for blocker_ref in node.get("blocked_by", []):
146
+ # blocker must be created before this node
147
+ edges.append((blocker_ref, node["ref"]))
148
+
149
+ # Also ensure parents are created before children
150
+ for node in validated_nodes:
151
+ parent_ref = node.get("parent_ref")
152
+ if parent_ref:
153
+ edges.append((parent_ref, node["ref"]))
154
+
155
+ if edges:
156
+ try:
157
+ sorted_refs = detect_cycles(edges)
158
+ except CycleDetectedError as exc:
159
+ raise ValueError(str(exc)) from exc
160
+ # Include any nodes not in the edge graph (isolated nodes)
161
+ sorted_refs_set = set(sorted_refs)
162
+ remaining = [r for r in node_map if r not in sorted_refs_set]
163
+ sorted_refs = sorted_refs + remaining
164
+ else:
165
+ sorted_refs = list(node_map.keys())
166
+
167
+ # In real execution, validate all cross-node references before any mutation.
168
+ if not dry_run:
169
+ for ref in sorted_refs:
170
+ if ref not in node_map:
171
+ continue
172
+ node = node_map[ref]
173
+ parent_ref = node.get("parent_ref")
174
+ if parent_ref and parent_ref not in node_map:
175
+ raise ValueError(f"parent_ref {parent_ref!r} for manifest ref {ref!r} could not be resolved")
176
+ for blocker_ref in node.get("blocked_by", []):
177
+ if blocker_ref not in node_map:
178
+ raise ValueError(f"blocked_by ref {blocker_ref!r} for manifest ref {ref!r} could not be resolved")
179
+
180
+ # Phase 1: Generate create operations in topological order
181
+ descriptors: list[OperationDescriptor] = []
182
+ ref_bindings: dict[str, str] = {} # manifest ref → provider identifier
183
+
184
+ for ref in sorted_refs:
185
+ if ref not in node_map:
186
+ continue
187
+ node = node_map[ref]
188
+ orch_key = generate_orchestration_key("create_issue", ref)
189
+ body = node.get("body", "")
190
+ if body is None:
191
+ body = ""
192
+ body_with_key = embed_orchestration_key(body, orch_key)
193
+
194
+ provider_params: dict[str, Any] = {
195
+ "title": node["title"],
196
+ "body": body_with_key,
197
+ "issue_type": node["issue_type"],
198
+ }
199
+ if node.get("parent_ref"):
200
+ provider_params["parent_ref"] = node["parent_ref"]
201
+ if node.get("labels"):
202
+ provider_params["labels"] = node["labels"]
203
+
204
+ if dry_run and not check_existing:
205
+ # Mode 1: planning-only dry-run — assemble descriptors only.
206
+ descriptors.append(
207
+ OperationDescriptor(
208
+ operation_type="create_issue",
209
+ orchestration_key=orch_key,
210
+ refs=(ref,),
211
+ status="dry-run",
212
+ provider_params=provider_params,
213
+ )
214
+ )
215
+ elif dry_run and check_existing:
216
+ # Mode 2: dry-run with existence checks
217
+ assert query_provider is not None
218
+ existing = query_provider.find_existing_issue(orch_key)
219
+ if existing is not None:
220
+ ref_bindings[ref] = existing.identifier
221
+ descriptors.append(
222
+ OperationDescriptor(
223
+ operation_type="create_issue",
224
+ orchestration_key=orch_key,
225
+ refs=(ref,),
226
+ status="existing",
227
+ provider_params=provider_params,
228
+ result=existing,
229
+ )
230
+ )
231
+ else:
232
+ descriptors.append(
233
+ OperationDescriptor(
234
+ operation_type="create_issue",
235
+ orchestration_key=orch_key,
236
+ refs=(ref,),
237
+ status="dry-run",
238
+ provider_params=provider_params,
239
+ )
240
+ )
241
+ else:
242
+ # Mode 3: real execution
243
+ parent_id: str | None = None
244
+ if node.get("parent_ref"):
245
+ parent_id = ref_bindings[node["parent_ref"]]
246
+
247
+ result = provider.create_issue(
248
+ title=node["title"],
249
+ body=body_with_key,
250
+ issue_type=node["issue_type"],
251
+ parent_id=parent_id,
252
+ labels=node.get("labels"),
253
+ idempotency_key=orch_key,
254
+ dry_run=False,
255
+ )
256
+ ref_bindings[ref] = result.identifier
257
+ descriptors.append(
258
+ OperationDescriptor(
259
+ operation_type="create_issue",
260
+ orchestration_key=orch_key,
261
+ refs=(ref,),
262
+ status=result.status,
263
+ provider_params=provider_params,
264
+ result=result,
265
+ )
266
+ )
267
+
268
+ # Phase 2: Generate link_subissue operations
269
+ for ref in sorted_refs:
270
+ if ref not in node_map:
271
+ continue
272
+ node = node_map[ref]
273
+ parent_ref = node.get("parent_ref")
274
+ if not parent_ref:
275
+ continue
276
+
277
+ orch_key = generate_orchestration_key("link_subissue", parent_ref, ref)
278
+ provider_params = {"parent_ref": parent_ref, "child_ref": ref}
279
+
280
+ if dry_run and not check_existing:
281
+ descriptors.append(
282
+ OperationDescriptor(
283
+ operation_type="link_subissue",
284
+ orchestration_key=orch_key,
285
+ refs=(parent_ref, ref),
286
+ status="dry-run",
287
+ provider_params=provider_params,
288
+ )
289
+ )
290
+ elif dry_run and check_existing:
291
+ assert query_provider is not None
292
+ parent_id = ref_bindings.get(parent_ref)
293
+ child_id = ref_bindings.get(ref)
294
+ if parent_id and child_id:
295
+ link_existing = query_provider.find_existing_link(parent_id, child_id)
296
+ if link_existing is not None:
297
+ descriptors.append(
298
+ OperationDescriptor(
299
+ operation_type="link_subissue",
300
+ orchestration_key=orch_key,
301
+ refs=(parent_ref, ref),
302
+ status="already-linked",
303
+ provider_params=provider_params,
304
+ result=link_existing,
305
+ )
306
+ )
307
+ else:
308
+ descriptors.append(
309
+ OperationDescriptor(
310
+ operation_type="link_subissue",
311
+ orchestration_key=orch_key,
312
+ refs=(parent_ref, ref),
313
+ status="dry-run",
314
+ provider_params=provider_params,
315
+ )
316
+ )
317
+ else:
318
+ descriptors.append(
319
+ OperationDescriptor(
320
+ operation_type="link_subissue",
321
+ orchestration_key=orch_key,
322
+ refs=(parent_ref, ref),
323
+ status="dry-run",
324
+ provider_params=provider_params,
325
+ )
326
+ )
327
+ else:
328
+ # Real execution
329
+ parent_id = ref_bindings[parent_ref]
330
+ child_id = ref_bindings[ref]
331
+ link_result = provider.link_subissue(parent_id, child_id, dry_run=False)
332
+ descriptors.append(
333
+ OperationDescriptor(
334
+ operation_type="link_subissue",
335
+ orchestration_key=orch_key,
336
+ refs=(parent_ref, ref),
337
+ status=link_result.status,
338
+ provider_params=provider_params,
339
+ result=link_result,
340
+ )
341
+ )
342
+
343
+ # Phase 3: Generate add_blocked_by operations
344
+ for ref in sorted_refs:
345
+ if ref not in node_map:
346
+ continue
347
+ node = node_map[ref]
348
+ for blocker_ref in node.get("blocked_by", []):
349
+ orch_key = generate_orchestration_key("add_blocked_by", ref, blocker_ref)
350
+ provider_params = {"issue_ref": ref, "blocked_by_ref": blocker_ref}
351
+
352
+ if dry_run and not check_existing:
353
+ descriptors.append(
354
+ OperationDescriptor(
355
+ operation_type="add_blocked_by",
356
+ orchestration_key=orch_key,
357
+ refs=(ref, blocker_ref),
358
+ status="dry-run",
359
+ provider_params=provider_params,
360
+ )
361
+ )
362
+ elif dry_run and check_existing:
363
+ assert query_provider is not None
364
+ issue_id = ref_bindings.get(ref)
365
+ blocker_id = ref_bindings.get(blocker_ref)
366
+ if issue_id and blocker_id:
367
+ dep_existing = query_provider.find_existing_dependency(issue_id, blocker_id)
368
+ if dep_existing is not None:
369
+ descriptors.append(
370
+ OperationDescriptor(
371
+ operation_type="add_blocked_by",
372
+ orchestration_key=orch_key,
373
+ refs=(ref, blocker_ref),
374
+ status="already-linked",
375
+ provider_params=provider_params,
376
+ result=dep_existing,
377
+ )
378
+ )
379
+ else:
380
+ descriptors.append(
381
+ OperationDescriptor(
382
+ operation_type="add_blocked_by",
383
+ orchestration_key=orch_key,
384
+ refs=(ref, blocker_ref),
385
+ status="dry-run",
386
+ provider_params=provider_params,
387
+ )
388
+ )
389
+ else:
390
+ descriptors.append(
391
+ OperationDescriptor(
392
+ operation_type="add_blocked_by",
393
+ orchestration_key=orch_key,
394
+ refs=(ref, blocker_ref),
395
+ status="dry-run",
396
+ provider_params=provider_params,
397
+ )
398
+ )
399
+ else:
400
+ # Real execution
401
+ issue_id = ref_bindings[ref]
402
+ blocker_id = ref_bindings[blocker_ref]
403
+ dep_result = provider.add_blocked_by(issue_id, blocker_id, dry_run=False)
404
+ descriptors.append(
405
+ OperationDescriptor(
406
+ operation_type="add_blocked_by",
407
+ orchestration_key=orch_key,
408
+ refs=(ref, blocker_ref),
409
+ status=dep_result.status,
410
+ provider_params=provider_params,
411
+ result=dep_result,
412
+ )
413
+ )
414
+
415
+ return OperationPlan(
416
+ operations=tuple(descriptors),
417
+ dry_run=dry_run,
418
+ check_existing=check_existing,
419
+ )
420
+
421
+
422
+ def execute_manifest(
423
+ manifest: dict[str, Any],
424
+ provider: IssueProvider,
425
+ ) -> OperationPlan:
426
+ """Execute a manifest against a provider (real mutations).
427
+
428
+ Convenience wrapper for ``plan_manifest(dry_run=False)``.
429
+
430
+ Args:
431
+ manifest: JSON manifest with nodes and edges.
432
+ provider: IssueProvider for real execution.
433
+
434
+ Returns:
435
+ OperationPlan with results from the executed operations.
436
+ """
437
+ return plan_manifest(manifest, provider, dry_run=False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-devtools
3
- Version: 0.2.332
3
+ Version: 0.2.333
4
4
  Summary: Agentic devtools integrate Jira, DevOps & more
5
5
  Author: ayaiayorg
6
6
  License-Expression: MIT
@@ -1,5 +1,5 @@
1
1
  agentic_devtools/__init__.py,sha256=J_Zw_vWKghk-cLmqI83hXQmSiS8zMhGIHM5WPLDkZuo,242
2
- agentic_devtools/_version.py,sha256=W05wKAPL9NTr_eL2ykSOaNckhAZGNqg5k74bBouriC0,524
2
+ agentic_devtools/_version.py,sha256=4iHxOBCshxxKQXkmD0BI3AmeulSZMr_G5ZVpaxUKYT0,524
3
3
  agentic_devtools/agdt_gitignore.py,sha256=aBPBQe7M0GLH8NIp1NsyN9ZiO80fNGOi46IcT5A4SK4,1569
4
4
  agentic_devtools/background_tasks.py,sha256=IVC1XJKQzPBP8wCRNCZX_iZCg9FJY_GBUzddSJj7xqw,17473
5
5
  agentic_devtools/config.py,sha256=DEVxTVZhsQbVQwO9qdB_1h19aBpjMzF6xJY_ePYLGLs,15629
@@ -15,7 +15,7 @@ agentic_devtools/_bundled_skills/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRk
15
15
  agentic_devtools/_bundled_skills/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  agentic_devtools/_bundled_skills/prompts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  agentic_devtools/_bundled_skills/workflow-analysis/SKILL.md,sha256=imOFULtiGqsFEljGx0ly7_ntnZAmLWuYAF0HL82MIVA,21153
18
- agentic_devtools/adapters/__init__.py,sha256=PDsuodDPaGL6wPcaW3lKQNfRmW10A9Gw8J1avTit1w4,7774
18
+ agentic_devtools/adapters/__init__.py,sha256=BVj0rIs6WIPee2_hxm2yj-WSdNi-PfrHFw33GK2yJho,8161
19
19
  agentic_devtools/adapters/base.py,sha256=7UIx89av0QOO01eqOFx9hQZC4HNhUPNe1myFBmL0NgY,6104
20
20
  agentic_devtools/adapters/copilot-instructions.md,sha256=xkz-aVd7cc2v_tPN9CzV67O0GbKZNmkmHXSwN_ULK_8,29223
21
21
  agentic_devtools/adapters/cycle_detection.py,sha256=93y3Yb1v2ungYeTJKxL8ztQltm8OFvsnLP67ZFVmoAQ,2350
@@ -25,12 +25,15 @@ agentic_devtools/adapters/factory.py,sha256=Z32q3wcZ196jWRlRcphRRDBqYMbTCRlz13EU
25
25
  agentic_devtools/adapters/github_adapter.py,sha256=ltdJveOCHs95zzJMERDZp9T66FCYgvRR6slzKBBkJxY,22324
26
26
  agentic_devtools/adapters/github_provider.py,sha256=6hvoaOFP-uOZQKGl2_XU8o9uR5xUzAWFFMHul3aKZuw,19992
27
27
  agentic_devtools/adapters/github_schema.py,sha256=V793iZqst3Dj_piX6CkSbn379nvItECs7PattF95WqU,6869
28
- agentic_devtools/adapters/issue_provider.py,sha256=rEGApNOP2F_rpZCindBQcJpTDU5p64TLNRBA6RsfacI,26205
28
+ agentic_devtools/adapters/idempotency_query_provider.py,sha256=vJ9hi2bKQIQsIxhnIqFWXKV38DmS9W3T1pVRtIZ908A,2135
29
+ agentic_devtools/adapters/issue_provider.py,sha256=E4QNTtKC2JGZEax9cF5bCVa-FybTN0FbWlMrGzgkyt0,28775
29
30
  agentic_devtools/adapters/issue_type_mapping.py,sha256=-Mh4fmr6Zd10CEGkDaFHVYiOJHUXFIWItAec5jmKu5c,14193
30
31
  agentic_devtools/adapters/jira_adapter.py,sha256=Fhkdm6YaTNW6wVKf1KSXlrT18XL-qRezBD1j9TMt03k,22525
31
- agentic_devtools/adapters/jira_provider.py,sha256=JIzU7IqStsUI50FvDGn2HpAHnLQ4YDwklY93e-Lx5kw,34234
32
+ agentic_devtools/adapters/jira_provider.py,sha256=mC-oP9-06B2xCczOXrzLbJRvk0HRzgYak7ZgYpK9wb8,37204
32
33
  agentic_devtools/adapters/markdown_adapter.py,sha256=ej6Je7yGtj8igtHiiG_7ZbTedGyN0G1HLloLuQoWGkM,25942
33
- agentic_devtools/adapters/orchestration_key.py,sha256=KmBQX3g6Fa9jbVrOr9IKT9Bj_K4wj3GBpoJRAO0qq30,2684
34
+ agentic_devtools/adapters/operation_plan.py,sha256=Lp46YSI3hz9UeZ253tlCzDufDZr9x4Jgk4LGwRq57Xc,4315
35
+ agentic_devtools/adapters/orchestration_key.py,sha256=-v2lM1HGSa6IIeeLy6vDZzxeeAN7O0ETkycuvLWRwIA,4059
36
+ agentic_devtools/adapters/plan_manifest.py,sha256=QozGmmQCFH5Z7HDYx3eMeI21M3PMuM_DW2qWFnQeCv0,18831
34
37
  agentic_devtools/adapters/retry.py,sha256=CsueXIs-sLgkCVgp_hspzNnQKi_Ty-FyVN-066WncRc,3736
35
38
  agentic_devtools/adapters/types.py,sha256=gqN2fq0i0gtMzCY1dCweZRlq3taAQNQEEHdqak5pbjI,8853
36
39
  agentic_devtools/cli/__init__.py,sha256=VgHQrGk0NuMaGMdvooFwz3nGY1NzVi6kX5Fr7wF-Tdw,40
@@ -889,8 +892,8 @@ agentic_devtools/_bundled_skills/prompts/speckit.plan.prompt.md,sha256=IJja5r2Sd
889
892
  agentic_devtools/_bundled_skills/prompts/speckit.specify.prompt.md,sha256=eyzE3GRi2hyW30a6xPYOU7q6MJf0skrD-baEGURYqpg,31
890
893
  agentic_devtools/_bundled_skills/prompts/speckit.tasks.prompt.md,sha256=iPxXwon5nV6dNcJV8-JoP3PssKUVXctNiG-C9SsRhB8,29
891
894
  agentic_devtools/_bundled_skills/prompts/speckit.taskstoissues.prompt.md,sha256=L5Y21PMSoUcPAAdHy2Jnf-wGVdi04jV_pPvyOJZfpm0,37
892
- agentic_devtools-0.2.332.dist-info/METADATA,sha256=2BJ-K1TYEWIAepVS_EtES8e15vcoX5DWhAWiTDoQOKU,29796
893
- agentic_devtools-0.2.332.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
894
- agentic_devtools-0.2.332.dist-info/entry_points.txt,sha256=Xs0VhRkREzs61FdX63Ed1qa1-WDeBppn7BvMdRIYYZ4,11395
895
- agentic_devtools-0.2.332.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
896
- agentic_devtools-0.2.332.dist-info/RECORD,,
895
+ agentic_devtools-0.2.333.dist-info/METADATA,sha256=67rnAlB09DjBFo7jRBoAQkswMf2MhNe58ciIOZephbM,29796
896
+ agentic_devtools-0.2.333.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
897
+ agentic_devtools-0.2.333.dist-info/entry_points.txt,sha256=Xs0VhRkREzs61FdX63Ed1qa1-WDeBppn7BvMdRIYYZ4,11395
898
+ agentic_devtools-0.2.333.dist-info/licenses/LICENSE,sha256=yBEDdICksxhBYLWoERKp9MTqwGnUF6Ryj9BTLwXTc6k,1082
899
+ agentic_devtools-0.2.333.dist-info/RECORD,,