devtorch-core 3.0.1__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 (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,188 @@
1
+ """HITL disagreement resolution for multi-agent sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import datetime as _dt
7
+ import json
8
+ import re
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from devtorch_core import GCCRepository
14
+ from devtorch_core.hitl import HITLChannel, HITLOrchestrator
15
+
16
+
17
+ @dataclasses.dataclass
18
+ class DisagreementRecord:
19
+ """
20
+ Durable record of a disagreement between agents on a concept.
21
+
22
+ Positions are free-form agent stances (e.g. a disclosure level or a design
23
+ choice) plus a confidence score. The resolver can auto-consolidate, ask
24
+ a human, or escalate.
25
+ """
26
+
27
+ disagreement_id: str
28
+ session_id: str
29
+ concept: str
30
+ positions: List[Dict[str, Any]] # [{agent_id, position, confidence}, ...]
31
+ state: str # "open" | "consolidated" | "escalated"
32
+ resolution: str
33
+ created_at: str
34
+ updated_at: str
35
+
36
+ def to_dict(self) -> dict:
37
+ return dataclasses.asdict(self)
38
+
39
+ @classmethod
40
+ def from_dict(cls, d: dict) -> "DisagreementRecord":
41
+ known = {f.name for f in dataclasses.fields(cls)}
42
+ return cls(**{k: v for k, v in d.items() if k in known})
43
+
44
+
45
+ class DisagreementResolver:
46
+ """
47
+ Records and resolves disagreements from multi-agent sessions.
48
+
49
+ Persisted layout: `.GCC/sessions/disagreements/<disagreement_id>.json`
50
+ """
51
+
52
+ def __init__(self, gcc_dir: Path, channels: Optional[List[HITLChannel]] = None) -> None:
53
+ self._gcc_dir = Path(gcc_dir)
54
+ self._dir = self._gcc_dir / "sessions" / "disagreements"
55
+ self._channels = channels or []
56
+
57
+ def _ensure_dir(self) -> None:
58
+ self._dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ def _persist(self, record: DisagreementRecord) -> Path:
61
+ self._ensure_dir()
62
+ path = self._dir / f"{record.disagreement_id}.json"
63
+ path.write_text(
64
+ json.dumps(record.to_dict(), indent=2, sort_keys=True) + "\n",
65
+ encoding="utf-8",
66
+ )
67
+ return path
68
+
69
+ def _load(self, disagreement_id: str) -> Optional[DisagreementRecord]:
70
+ path = self._dir / f"{disagreement_id}.json"
71
+ if not path.exists():
72
+ return None
73
+ try:
74
+ return DisagreementRecord.from_dict(
75
+ json.loads(path.read_text(encoding="utf-8"))
76
+ )
77
+ except (json.JSONDecodeError, TypeError, ValueError):
78
+ return None
79
+
80
+ def record_disagreement(
81
+ self,
82
+ session_id: str,
83
+ concept: str,
84
+ positions: List[Dict[str, Any]],
85
+ ) -> DisagreementRecord:
86
+ """Record a new disagreement and persist it."""
87
+ now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
88
+ record = DisagreementRecord(
89
+ disagreement_id=str(uuid.uuid4()),
90
+ session_id=session_id,
91
+ concept=concept,
92
+ positions=positions,
93
+ state="open",
94
+ resolution="",
95
+ created_at=now,
96
+ updated_at=now,
97
+ )
98
+ self._persist(record)
99
+ return record
100
+
101
+ def list_disagreements(self, session_id: Optional[str] = None) -> List[DisagreementRecord]:
102
+ """Return all disagreements, optionally filtered by session_id."""
103
+ results: List[DisagreementRecord] = []
104
+ if not self._dir.exists():
105
+ return results
106
+ for f in sorted(self._dir.glob("*.json")):
107
+ try:
108
+ d = json.loads(f.read_text(encoding="utf-8"))
109
+ if session_id is not None and d.get("session_id") != session_id:
110
+ continue
111
+ results.append(DisagreementRecord.from_dict(d))
112
+ except (json.JSONDecodeError, TypeError, ValueError):
113
+ continue
114
+ return results
115
+
116
+ def resolve(
117
+ self,
118
+ disagreement_id: str,
119
+ human_decision: Optional[str] = None,
120
+ channels: Optional[List[HITLChannel]] = None,
121
+ ) -> DisagreementRecord:
122
+ """
123
+ Resolve a disagreement.
124
+
125
+ - If *human_decision* is provided, record it as the consolidated resolution.
126
+ - Otherwise, if the disagreement needs human input, run the existing
127
+ HITLOrchestrator flow. If the orchestrator returns a resolved/auto
128
+ resolved consolidation record, use its decision; otherwise escalate.
129
+ - If no human input is required, auto-consolidate by selecting the
130
+ highest-confidence position.
131
+ """
132
+ record = self._load(disagreement_id)
133
+ if record is None:
134
+ raise ValueError(f"Disagreement {disagreement_id!r} not found")
135
+
136
+ now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
137
+
138
+ if human_decision is not None:
139
+ record.resolution = human_decision
140
+ record.state = "consolidated"
141
+ elif self.needs_human_input(record):
142
+ hitl_channels = channels if channels is not None else self._channels
143
+ # gcc_dir is the .GCC directory; GCCRepository expects the project root.
144
+ repo = GCCRepository(self._gcc_dir.parent)
145
+ orchestrator = HITLOrchestrator(repo, channels=hitl_channels, poll_attempts=1)
146
+ consolidation = orchestrator.run(record.concept)
147
+ if consolidation is not None and consolidation.state in ("resolved", "auto_resolved"):
148
+ record.resolution = consolidation.decision or ""
149
+ record.state = "consolidated"
150
+ else:
151
+ record.state = "escalated"
152
+ else:
153
+ best = max(
154
+ record.positions,
155
+ key=lambda p: float(p.get("confidence", 0.0)),
156
+ )
157
+ record.resolution = str(best.get("position", ""))
158
+ record.state = "consolidated"
159
+
160
+ record.updated_at = now
161
+ self._persist(record)
162
+ return record
163
+
164
+ def needs_human_input(self, disagreement: DisagreementRecord) -> bool:
165
+ """
166
+ Return True when the disagreement should be escalated to a human.
167
+
168
+ Triggers:
169
+ - confidence gap between the highest and lowest position > 0.3
170
+ - positions contain both PUBLIC and PRIVATE disclosure stances
171
+ """
172
+ positions = disagreement.positions
173
+ if not positions:
174
+ return False
175
+
176
+ confidences = [float(p.get("confidence", 0.0)) for p in positions]
177
+ if max(confidences) - min(confidences) > 0.3:
178
+ return True
179
+
180
+ public_seen = any(
181
+ re.search(r"\bPUBLIC\b", str(p.get("position", "")))
182
+ for p in positions
183
+ )
184
+ private_seen = any(
185
+ re.search(r"\bPRIVATE\b", str(p.get("position", "")))
186
+ for p in positions
187
+ )
188
+ return public_seen and private_seen
@@ -0,0 +1,114 @@
1
+ """
2
+ Sprint 28 — Session orchestration and sub-task planning models.
3
+
4
+ Lightweight dataclasses for multi-agent sessions, subtasks, and agent role
5
+ assignments. All objects are plain dicts round-trippable via to_dict/from_dict
6
+ so they can be stored as JSON under `.GCC/sessions/`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import datetime as dt
12
+ from typing import Any, Dict, List
13
+
14
+
15
+ # Session lifecycle statuses as string constants.
16
+ class SessionStatus:
17
+ PENDING = "pending"
18
+ PLANNING = "planning"
19
+ ACTIVE = "active"
20
+ PAUSED = "paused"
21
+ COMPLETED = "completed"
22
+ FAILED = "failed"
23
+
24
+
25
+ # Module-level aliases for convenience.
26
+ PENDING = SessionStatus.PENDING
27
+ PLANNING = SessionStatus.PLANNING
28
+ ACTIVE = SessionStatus.ACTIVE
29
+ PAUSED = SessionStatus.PAUSED
30
+ COMPLETED = SessionStatus.COMPLETED
31
+ FAILED = SessionStatus.FAILED
32
+
33
+
34
+ @dataclasses.dataclass
35
+ class AgentAssignment:
36
+ agent_id: str
37
+ role: str
38
+ capabilities: List[str] = dataclasses.field(default_factory=list)
39
+
40
+ def to_dict(self) -> dict:
41
+ return dataclasses.asdict(self)
42
+
43
+ @classmethod
44
+ def from_dict(cls, data: dict) -> "AgentAssignment":
45
+ return cls(**data)
46
+
47
+
48
+ @dataclasses.dataclass
49
+ class Subtask:
50
+ subtask_id: str
51
+ session_id: str
52
+ description: str
53
+ dependencies: List[str] = dataclasses.field(default_factory=list)
54
+ status: str = PENDING
55
+ assigned_role: str = ""
56
+ concept: str = ""
57
+ assumptions: Dict[str, Any] = dataclasses.field(default_factory=dict)
58
+ created_at: str = dataclasses.field(default_factory=lambda: _now_iso())
59
+
60
+ def to_dict(self) -> dict:
61
+ return dataclasses.asdict(self)
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict) -> "Subtask":
65
+ return cls(**data)
66
+
67
+
68
+ @dataclasses.dataclass
69
+ class Session:
70
+ session_id: str
71
+ name: str
72
+ problem: str
73
+ status: str = PENDING
74
+ agent_roles: List[AgentAssignment] = dataclasses.field(default_factory=list)
75
+ subtasks: List[Subtask] = dataclasses.field(default_factory=list)
76
+ assumptions: Dict[str, Any] = dataclasses.field(default_factory=dict)
77
+ created_at: str = dataclasses.field(default_factory=lambda: _now_iso())
78
+ updated_at: str = dataclasses.field(default_factory=lambda: _now_iso())
79
+
80
+ def to_dict(self) -> dict:
81
+ return {
82
+ "session_id": self.session_id,
83
+ "name": self.name,
84
+ "problem": self.problem,
85
+ "status": self.status,
86
+ "agent_roles": [a.to_dict() for a in self.agent_roles],
87
+ "subtasks": [s.to_dict() for s in self.subtasks],
88
+ "assumptions": dict(self.assumptions),
89
+ "created_at": self.created_at,
90
+ "updated_at": self.updated_at,
91
+ }
92
+
93
+ @classmethod
94
+ def from_dict(cls, data: dict) -> "Session":
95
+ return cls(
96
+ session_id=data["session_id"],
97
+ name=data["name"],
98
+ problem=data["problem"],
99
+ status=data["status"],
100
+ agent_roles=[AgentAssignment.from_dict(a) for a in data.get("agent_roles", [])],
101
+ subtasks=[Subtask.from_dict(s) for s in data.get("subtasks", [])],
102
+ assumptions=data.get("assumptions", {}),
103
+ created_at=data["created_at"],
104
+ updated_at=data["updated_at"],
105
+ )
106
+
107
+
108
+ def _now_iso() -> str:
109
+ return dt.datetime.now(tz=dt.timezone.utc).isoformat()
110
+
111
+
112
+ def is_terminal_status(status: str) -> bool:
113
+ """Return True if a session status is terminal."""
114
+ return status in (SessionStatus.COMPLETED, SessionStatus.FAILED)
@@ -0,0 +1,182 @@
1
+ """
2
+ Sprint 28 — Session orchestrator.
3
+
4
+ Manages the lifecycle of a multi-agent session: creation, persistence under
5
+ `.GCC/sessions/<id>.json`, updates, and closure. When `DEVTORCH_SESSION_CLOUD_URL`
6
+ is set, session metadata is also POSTed to that endpoint. If the endpoint is
7
+ unreachable, the orchestrator falls back to local storage and logs a
8
+ `SESSION_CLOUD_FALLBACK` event to `.GCC/events.log.jsonl`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import urllib.error
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Any, List, Optional
19
+
20
+ from devtorch_core.gcc import GCCRepository
21
+ from devtorch_core.storage import LocalFileBackend, StorageBackend
22
+
23
+ from .models import AgentAssignment, Session, SessionStatus
24
+
25
+
26
+ SESSION_DIR_NAME = "sessions"
27
+ SESSION_COUNTER_FILE = ".counter"
28
+ SESSION_CLOUD_URL_ENV = "DEVTORCH_SESSION_CLOUD_URL"
29
+ SESSION_CLOUD_FALLBACK_EVENT = "SESSION_CLOUD_FALLBACK"
30
+
31
+
32
+ class SessionOrchestrator:
33
+ """Create, persist, and update multi-agent sessions."""
34
+
35
+ def __init__(
36
+ self,
37
+ gcc_dir: str | Path,
38
+ backend: Optional[StorageBackend] = None,
39
+ ) -> None:
40
+ """
41
+ Parameters
42
+ ----------
43
+ gcc_dir: Path to the `.GCC` directory for this project.
44
+ backend: Optional StorageBackend. Defaults to LocalFileBackend.
45
+ """
46
+ self.gcc_dir = Path(gcc_dir)
47
+ self._repo = GCCRepository.at(self.gcc_dir.parent)
48
+ self._backend = backend if backend is not None else LocalFileBackend(self.gcc_dir)
49
+ self._sessions_key = SESSION_DIR_NAME
50
+
51
+ def _session_key(self, session_id: str) -> str:
52
+ return f"{self._sessions_key}/{session_id}.json"
53
+
54
+ def _counter_key(self) -> str:
55
+ return f"{self._sessions_key}/{SESSION_COUNTER_FILE}"
56
+
57
+ def _ensure_gcc(self) -> None:
58
+ """Initialize the `.GCC` repository if it has not been created yet."""
59
+ if not self._repo.is_initialized():
60
+ self._repo.init()
61
+
62
+ def _next_session_id(self, name: str, problem: str) -> str:
63
+ """Deterministic session ID based on content + counter (no wall-clock)."""
64
+ counter = 0
65
+ counter_key = self._counter_key()
66
+ if self._backend.exists(counter_key):
67
+ raw = self._backend.read_bytes(counter_key).decode("utf-8").strip()
68
+ try:
69
+ counter = int(raw)
70
+ except ValueError:
71
+ counter = 0
72
+ counter += 1
73
+ self._backend.write_bytes(counter_key, str(counter).encode("utf-8"))
74
+ content = f"session:{name}:{problem}:{counter}"
75
+ hash_part = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
76
+ return f"s{counter:08x}-{hash_part}"
77
+
78
+ def _write_session(self, session: Session) -> None:
79
+ session.updated_at = _now_iso()
80
+ self._backend.write_bytes(
81
+ self._session_key(session.session_id),
82
+ json.dumps(session.to_dict(), indent=2, sort_keys=True).encode("utf-8"),
83
+ )
84
+
85
+ def _read_session(self, session_id: str) -> Session:
86
+ key = self._session_key(session_id)
87
+ if not self._backend.exists(key):
88
+ raise FileNotFoundError(f"Session not found: {session_id}")
89
+ data = json.loads(self._backend.read_bytes(key).decode("utf-8"))
90
+ return Session.from_dict(data)
91
+
92
+ def _log_cloud_fallback(self, session: Session, error: str) -> None:
93
+ """Log a SESSION_CLOUD_FALLBACK event to `.GCC/events.log.jsonl`."""
94
+ self._ensure_gcc()
95
+ self._repo._append_event(
96
+ event_type=SESSION_CLOUD_FALLBACK_EVENT,
97
+ payload={
98
+ "session_id": session.session_id,
99
+ "name": session.name,
100
+ "error": error,
101
+ "cloud_url": os.environ.get(SESSION_CLOUD_URL_ENV, ""),
102
+ },
103
+ )
104
+
105
+ def _post_to_cloud(self, session: Session) -> None:
106
+ """POST session metadata to the configured cloud URL if any."""
107
+ cloud_url = os.environ.get(SESSION_CLOUD_URL_ENV, "").strip()
108
+ if not cloud_url:
109
+ return
110
+ try:
111
+ data = json.dumps(session.to_dict(), sort_keys=True).encode("utf-8")
112
+ req = urllib.request.Request(
113
+ cloud_url,
114
+ data=data,
115
+ headers={"Content-Type": "application/json"},
116
+ method="POST",
117
+ )
118
+ with urllib.request.urlopen(req, timeout=5) as resp:
119
+ resp.read()
120
+ except Exception as exc: # noqa: BLE001
121
+ self._log_cloud_fallback(session, str(exc))
122
+
123
+ def start_session(
124
+ self,
125
+ name: str,
126
+ problem: str,
127
+ agent_assignments: Optional[List[AgentAssignment]] = None,
128
+ ) -> Session:
129
+ """Create a new session and persist it locally (and to the cloud if configured)."""
130
+ self._ensure_gcc()
131
+ session_id = self._next_session_id(name, problem)
132
+ session = Session(
133
+ session_id=session_id,
134
+ name=name,
135
+ problem=problem,
136
+ status=SessionStatus.PLANNING,
137
+ agent_roles=agent_assignments or [],
138
+ )
139
+ self._write_session(session)
140
+ self._post_to_cloud(session)
141
+ return session
142
+
143
+ def list_sessions(self) -> List[Session]:
144
+ """Return all stored sessions as `Session` objects."""
145
+ keys = self._backend.list_keys(self._sessions_key)
146
+ sessions: List[Session] = []
147
+ for key in keys:
148
+ if key.endswith(".json") and not key.endswith(f"/{SESSION_COUNTER_FILE}"):
149
+ try:
150
+ data = json.loads(self._backend.read_bytes(key).decode("utf-8"))
151
+ sessions.append(Session.from_dict(data))
152
+ except (json.JSONDecodeError, OSError):
153
+ continue
154
+ return sessions
155
+
156
+ def get_session(self, session_id: str) -> Session:
157
+ """Load a session by ID."""
158
+ return self._read_session(session_id)
159
+
160
+ def update_session(self, session_id: str, **kwargs: Any) -> Session:
161
+ """Update a session's fields and persist the change."""
162
+ session = self._read_session(session_id)
163
+ for key, value in kwargs.items():
164
+ if hasattr(session, key):
165
+ setattr(session, key, value)
166
+ self._write_session(session)
167
+ self._post_to_cloud(session)
168
+ return session
169
+
170
+ def close_session(self, session_id: str) -> Session:
171
+ """Mark a session as completed."""
172
+ return self.update_session(session_id, status=SessionStatus.COMPLETED)
173
+
174
+ def save_session(self, session: Session) -> None:
175
+ """Persist a fully constructed Session object (used by tests and simulators)."""
176
+ self._write_session(session)
177
+
178
+
179
+ def _now_iso() -> str:
180
+ from datetime import datetime, timezone
181
+
182
+ return datetime.now(tz=timezone.utc).isoformat()
@@ -0,0 +1,169 @@
1
+ """
2
+ Sprint 28 — Sub-task planner.
3
+
4
+ Decomposes a session problem into a list of `Subtask` objects with dependency
5
+ tracking. The default strategy is deterministic rule-based: it parses numbered
6
+ or bulleted lists and falls back to sentence splitting. Dependency links can be
7
+ expressed with `[after:N]` tags where N is a 1-based subtask index.
8
+
9
+ For tests, the decomposition strategy can be injected via the `strategy`
10
+ callable constructor argument.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ from typing import Callable, List, Optional
17
+
18
+ from .models import Session, SessionStatus, Subtask
19
+
20
+
21
+ _AFTER_TAG_RE = re.compile(r"\[after:(\d+)\]")
22
+
23
+
24
+ def _default_strategy(problem: str) -> List[dict]:
25
+ """
26
+ Deterministic rule-based decomposition.
27
+
28
+ 1. Treat lines that begin with a number, bullet, or dash as separate items.
29
+ 2. If no such lines are found, split on sentence boundaries.
30
+ 3. If the problem is empty, return a single item that captures the original
31
+ problem text.
32
+ """
33
+ items = []
34
+ for line in problem.splitlines():
35
+ line = line.strip()
36
+ if not line:
37
+ continue
38
+ match = re.match(r"^(?:\d+[\.\)]\s*|[-\*]\s*)(.*)$", line)
39
+ if match:
40
+ items.append(match.group(1).strip())
41
+
42
+ if not items:
43
+ sentences = [s.strip() for s in problem.split(".") if s.strip()]
44
+ if not sentences:
45
+ return [{"description": problem, "dependencies": []}]
46
+ items = sentences
47
+
48
+ return [{"description": item, "dependencies": []} for item in items]
49
+
50
+
51
+ def _extract_after_tags(description: str) -> tuple[str, List[str]]:
52
+ """Return (clean_description, list_of_1_based_dependency_indices)."""
53
+ deps: List[str] = []
54
+
55
+ def _collect(match: re.Match) -> str:
56
+ deps.append(match.group(1))
57
+ return ""
58
+
59
+ clean = _AFTER_TAG_RE.sub(_collect, description).strip()
60
+ return clean, deps
61
+
62
+
63
+ class SubtaskPlanner:
64
+ """Decompose problems into subtasks and track their execution order."""
65
+
66
+ def __init__(self, strategy: Optional[Callable[[str], List[dict]]] = None) -> None:
67
+ self._strategy = strategy if strategy is not None else _default_strategy
68
+ self._counter = 0
69
+
70
+ def _next_subtask_id(self, session_id: str, description: str) -> str:
71
+ """Deterministic subtask ID based on content + counter (no wall-clock)."""
72
+ self._counter += 1
73
+ content = f"subtask:{session_id}:{description}:{self._counter}"
74
+ hash_part = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
75
+ return f"t{self._counter:08x}-{hash_part}"
76
+
77
+ def plan(
78
+ self,
79
+ session_id: str,
80
+ problem: str,
81
+ orchestrator: "SessionOrchestrator", # type: ignore[name-defined]
82
+ ) -> List[Subtask]:
83
+ """Decompose `problem` and store the resulting subtasks in the session."""
84
+ # Import locally to avoid circular dependency at module load time.
85
+ from .orchestrator import SessionOrchestrator
86
+
87
+ if not isinstance(orchestrator, SessionOrchestrator):
88
+ raise TypeError("orchestrator must be a SessionOrchestrator")
89
+
90
+ items = self._strategy(problem)
91
+
92
+ # Parse descriptions and dependency indices before IDs are generated.
93
+ parsed: List[tuple[str, List[str]]] = []
94
+ for item in items:
95
+ desc = item.get("description", "")
96
+ explicit_deps = item.get("dependencies", [])
97
+ clean_desc, after_indices = _extract_after_tags(desc)
98
+ parsed.append((clean_desc, after_indices + explicit_deps))
99
+
100
+ # Generate subtasks with deterministic IDs based on the cleaned content.
101
+ subtasks: List[Subtask] = []
102
+ for clean_desc, _ in parsed:
103
+ subtask_id = self._next_subtask_id(session_id, clean_desc)
104
+ subtasks.append(
105
+ Subtask(
106
+ subtask_id=subtask_id,
107
+ session_id=session_id,
108
+ description=clean_desc,
109
+ )
110
+ )
111
+
112
+ # Resolve [after:N] indices to actual subtask IDs.
113
+ for i, (_, dep_indices) in enumerate(parsed):
114
+ resolved: List[str] = []
115
+ for idx in dep_indices:
116
+ try:
117
+ n = int(idx) - 1
118
+ except ValueError:
119
+ continue
120
+ if 0 <= n < len(subtasks):
121
+ resolved.append(subtasks[n].subtask_id)
122
+ subtasks[i].dependencies = resolved
123
+
124
+ orchestrator.update_session(
125
+ session_id,
126
+ subtasks=subtasks,
127
+ status=SessionStatus.ACTIVE,
128
+ )
129
+ return subtasks
130
+
131
+ def next_subtask(
132
+ self,
133
+ session_id: str,
134
+ orchestrator: "SessionOrchestrator", # type: ignore[name-defined]
135
+ ) -> Optional[Subtask]:
136
+ """Return the next pending subtask whose dependencies are all completed."""
137
+ from .orchestrator import SessionOrchestrator
138
+
139
+ if not isinstance(orchestrator, SessionOrchestrator):
140
+ raise TypeError("orchestrator must be a SessionOrchestrator")
141
+
142
+ session = orchestrator.get_session(session_id)
143
+ completed = {s.subtask_id for s in session.subtasks if s.status == SessionStatus.COMPLETED}
144
+ for subtask in session.subtasks:
145
+ if subtask.status == SessionStatus.PENDING:
146
+ if all(dep in completed for dep in subtask.dependencies):
147
+ return subtask
148
+ return None
149
+
150
+ def mark_subtask(
151
+ self,
152
+ session_id: str,
153
+ subtask_id: str,
154
+ status: str,
155
+ orchestrator: "SessionOrchestrator", # type: ignore[name-defined]
156
+ ) -> Optional[Subtask]:
157
+ """Update the status of a subtask and persist the session."""
158
+ from .orchestrator import SessionOrchestrator
159
+
160
+ if not isinstance(orchestrator, SessionOrchestrator):
161
+ raise TypeError("orchestrator must be a SessionOrchestrator")
162
+
163
+ session = orchestrator.get_session(session_id)
164
+ for subtask in session.subtasks:
165
+ if subtask.subtask_id == subtask_id:
166
+ subtask.status = status
167
+ orchestrator.update_session(session_id, subtasks=session.subtasks)
168
+ return subtask
169
+ return None