cohorte-engine 1.0.0a1__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 (56) hide show
  1. cohorte/__init__.py +3 -0
  2. cohorte/adapters/__init__.py +1 -0
  3. cohorte/adapters/claude.py +438 -0
  4. cohorte/adapters/codex.py +638 -0
  5. cohorte/adapters/events.py +157 -0
  6. cohorte/adapters/figma.py +75 -0
  7. cohorte/adapters/git.py +146 -0
  8. cohorte/adapters/graphify.py +153 -0
  9. cohorte/adapters/hosting.py +166 -0
  10. cohorte/adapters/native_login.py +126 -0
  11. cohorte/adapters/providers.py +64 -0
  12. cohorte/adapters/serena.py +181 -0
  13. cohorte/application/__init__.py +3 -0
  14. cohorte/application/alignment.py +192 -0
  15. cohorte/application/context.py +367 -0
  16. cohorte/application/delivery.py +235 -0
  17. cohorte/application/discovery.py +106 -0
  18. cohorte/application/durable.py +231 -0
  19. cohorte/application/fleet.py +395 -0
  20. cohorte/application/intake.py +137 -0
  21. cohorte/application/kanban.py +154 -0
  22. cohorte/application/maintenance.py +475 -0
  23. cohorte/application/metrics.py +194 -0
  24. cohorte/application/migration.py +282 -0
  25. cohorte/application/multisurface.py +560 -0
  26. cohorte/application/patch.py +217 -0
  27. cohorte/application/preparation.py +349 -0
  28. cohorte/application/readiness.py +44 -0
  29. cohorte/application/service.py +126 -0
  30. cohorte/application/vertical.py +377 -0
  31. cohorte/assets/prompts/README.md +4 -0
  32. cohorte/cli/__init__.py +1 -0
  33. cohorte/cli/main.py +1576 -0
  34. cohorte/domain/__init__.py +1 -0
  35. cohorte/domain/auth.py +125 -0
  36. cohorte/domain/errors.py +67 -0
  37. cohorte/domain/evidence.py +100 -0
  38. cohorte/domain/models.py +532 -0
  39. cohorte/domain/redaction.py +36 -0
  40. cohorte/execution/__init__.py +3 -0
  41. cohorte/execution/checks.py +107 -0
  42. cohorte/execution/scheduler.py +53 -0
  43. cohorte/persistence/__init__.py +3 -0
  44. cohorte/persistence/sqlite.py +1204 -0
  45. cohorte/protocol/__init__.py +3 -0
  46. cohorte/protocol/models.py +52 -0
  47. cohorte/protocol/rpc.py +332 -0
  48. cohorte/py.typed +1 -0
  49. cohorte/service/__init__.py +1 -0
  50. cohorte/service/host.py +316 -0
  51. cohorte/service/windows_pipe.py +235 -0
  52. cohorte_engine-1.0.0a1.dist-info/METADATA +200 -0
  53. cohorte_engine-1.0.0a1.dist-info/RECORD +56 -0
  54. cohorte_engine-1.0.0a1.dist-info/WHEEL +4 -0
  55. cohorte_engine-1.0.0a1.dist-info/entry_points.txt +2 -0
  56. cohorte_engine-1.0.0a1.dist-info/licenses/LICENSE +4 -0
cohorte/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Cohorte public package."""
2
+
3
+ __version__ = "1.0.0a1"
@@ -0,0 +1 @@
1
+ """External adapters. Imports never start a provider or authentication flow."""
@@ -0,0 +1,438 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from collections.abc import Callable, Mapping
9
+ from contextlib import suppress
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any, NoReturn, TypeVar
13
+
14
+ from cohorte.adapters.events import AgentEvents, AgentEventSink, claude_usage
15
+ from cohorte.application.durable import RunStopped
16
+ from cohorte.application.preparation import (
17
+ BrainstormContribution,
18
+ BrainstormPerspectiveTurn,
19
+ BrainstormSynthesis,
20
+ BrainstormSynthesisTurn,
21
+ )
22
+ from cohorte.application.vertical import AgentReport, AgentReview
23
+ from cohorte.domain.auth import (
24
+ AccountStatus,
25
+ BillingEvidence,
26
+ Capability,
27
+ CapabilitySet,
28
+ ConnectionState,
29
+ EffectiveAuthMode,
30
+ require_subscription,
31
+ )
32
+ from cohorte.domain.errors import CohorteError, ErrorCode
33
+ from cohorte.domain.models import RunStatus, StrictModel
34
+
35
+ StructuredOutput = TypeVar("StructuredOutput", bound=StrictModel)
36
+ _ROUTING_KEYS = frozenset(
37
+ {
38
+ "ANTHROPIC_API_KEY",
39
+ "ANTHROPIC_AUTH_TOKEN",
40
+ "ANTHROPIC_BASE_URL",
41
+ "CLAUDE_CODE_OAUTH_TOKEN",
42
+ "CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
43
+ "CLAUDE_CODE_OAUTH_SCOPES",
44
+ "CLAUDE_CODE_USE_BEDROCK",
45
+ "CLAUDE_CODE_USE_VERTEX",
46
+ "CLAUDE_CODE_USE_FOUNDRY",
47
+ "AWS_PROFILE",
48
+ "AWS_ACCESS_KEY_ID",
49
+ "GOOGLE_APPLICATION_CREDENTIALS",
50
+ }
51
+ )
52
+
53
+
54
+ def sanitized_claude_env(source: Mapping[str, str] | None = None) -> dict[str, str]:
55
+ env = dict(os.environ if source is None else source)
56
+ for key in _ROUTING_KEYS:
57
+ env.pop(key, None)
58
+ return env
59
+
60
+
61
+ def claude_capabilities(version: str | None) -> CapabilitySet:
62
+ supported = Capability(support="supported", version=version)
63
+ unknown = Capability(support="unknown", version=version)
64
+ return CapabilitySet(
65
+ structured_output=supported,
66
+ streaming=supported,
67
+ tool_permissions=supported,
68
+ user_questions=unknown,
69
+ interrupt=unknown,
70
+ session_resume=unknown,
71
+ read_only_role=unknown,
72
+ native_auth_status=supported,
73
+ model_catalog=unknown,
74
+ usage_reporting=unknown,
75
+ )
76
+
77
+
78
+ def raise_claude_result_error(result: str | None) -> NoReturn:
79
+ if result and "organization has disabled claude subscription access" in result.lower():
80
+ raise CohorteError(
81
+ ErrorCode.PROVIDER_UNAVAILABLE,
82
+ "Claude subscription access is disabled for this organization",
83
+ "the phase cannot start through the subscription",
84
+ remediation="ask the organization admin to enable access or select an eligible Claude account",
85
+ )
86
+ raise CohorteError(
87
+ ErrorCode.OUTPUT_INVALID,
88
+ "Claude turn failed",
89
+ "the workflow phase was rejected",
90
+ remediation="inspect provider diagnostics and retry the phase",
91
+ )
92
+
93
+
94
+ def inspect_claude_account(
95
+ executable: str | None = None, environ: Mapping[str, str] | None = None
96
+ ) -> AccountStatus:
97
+ source = os.environ if environ is None else environ
98
+ executable = executable or shutil.which("claude")
99
+ now = datetime.now(UTC)
100
+ version: str | None = None
101
+ if executable is None:
102
+ connection, mode, evidence = (
103
+ ConnectionState.UNAVAILABLE,
104
+ EffectiveAuthMode.UNKNOWN,
105
+ BillingEvidence.UNVERIFIED,
106
+ )
107
+ else:
108
+ env = sanitized_claude_env(source)
109
+ try:
110
+ version_result = subprocess.run(
111
+ [executable, "--version"],
112
+ capture_output=True,
113
+ text=True,
114
+ check=False,
115
+ timeout=5,
116
+ env=env,
117
+ )
118
+ if version_result.returncode == 0:
119
+ version = (version_result.stdout or version_result.stderr).strip()[:200]
120
+ result = subprocess.run(
121
+ [executable, "auth", "status", "--json"],
122
+ capture_output=True,
123
+ text=True,
124
+ check=False,
125
+ timeout=10,
126
+ env=env,
127
+ )
128
+ data = json.loads(result.stdout) if result.returncode == 0 else {}
129
+ if not isinstance(data, dict):
130
+ data = {}
131
+ except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError):
132
+ data = {}
133
+ if data.get("loggedIn") is True:
134
+ connection = ConnectionState.CONNECTED
135
+ if (
136
+ data.get("authMethod") == "claude.ai"
137
+ and data.get("apiProvider") == "firstParty"
138
+ and data.get("subscriptionType")
139
+ ):
140
+ mode, evidence = EffectiveAuthMode.SUBSCRIPTION, BillingEvidence.RUNTIME_REPORTED
141
+ elif data.get("apiProvider") not in (None, "firstParty"):
142
+ mode, evidence = EffectiveAuthMode.THIRD_PARTY, BillingEvidence.RUNTIME_REPORTED
143
+ elif data.get("authMethod") == "api_key":
144
+ mode, evidence = EffectiveAuthMode.API, BillingEvidence.RUNTIME_REPORTED
145
+ else:
146
+ mode, evidence = EffectiveAuthMode.UNKNOWN, BillingEvidence.UNVERIFIED
147
+ else:
148
+ connection, mode, evidence = (
149
+ ConnectionState.LOGIN_REQUIRED,
150
+ EffectiveAuthMode.UNKNOWN,
151
+ BillingEvidence.UNVERIFIED,
152
+ )
153
+ # An override in the parent process makes the effective route ambiguous even
154
+ # when the isolated CLI status command reports a subscription account.
155
+ if any(source.get(key) for key in _ROUTING_KEYS) and connection == ConnectionState.CONNECTED:
156
+ mode, evidence = EffectiveAuthMode.UNKNOWN, BillingEvidence.UNVERIFIED
157
+ return AccountStatus(
158
+ account_id="claude-native",
159
+ connection_state=connection,
160
+ effective_auth_mode=mode,
161
+ billing_evidence=evidence,
162
+ capabilities=claude_capabilities(version),
163
+ runtime_version=version,
164
+ checked_at=now,
165
+ )
166
+
167
+
168
+ class ClaudeAdapter:
169
+ def __init__(
170
+ self,
171
+ cwd: Path,
172
+ environ: Mapping[str, str] | None = None,
173
+ model: str | None = None,
174
+ stop_requested: Callable[[], RunStatus | None] | None = None,
175
+ event_sink: AgentEventSink | None = None,
176
+ ) -> None:
177
+ self.cwd = cwd.resolve(strict=True)
178
+ self.environ = dict(os.environ if environ is None else environ)
179
+ self.model = model
180
+ self.stop_requested = stop_requested
181
+ self.event_sink = event_sink
182
+
183
+ def _require_subscription(self) -> AccountStatus:
184
+ status = inspect_claude_account(environ=self.environ)
185
+ require_subscription(status)
186
+ return status
187
+
188
+ def _structured_turn_with_session(
189
+ self,
190
+ workspace: Path,
191
+ prompt: str,
192
+ output: type[StructuredOutput],
193
+ read_only: bool,
194
+ phase: str = "probe",
195
+ ) -> tuple[StructuredOutput, str]:
196
+ if self.stop_requested is not None:
197
+ stopped = self.stop_requested()
198
+ if stopped is not None:
199
+ raise RunStopped(stopped.value)
200
+ self._require_subscription()
201
+ events = AgentEvents(self.event_sink, "claude", phase, read_only)
202
+ try:
203
+ from claude_agent_sdk import (
204
+ ClaudeAgentOptions,
205
+ ClaudeSDKClient,
206
+ HookContext,
207
+ HookInput,
208
+ HookMatcher,
209
+ ResultMessage,
210
+ )
211
+ from claude_agent_sdk.types import SyncHookJSONOutput
212
+ except ImportError as error:
213
+ raise CohorteError(
214
+ ErrorCode.PROVIDER_UNAVAILABLE,
215
+ "Claude Agent SDK is not installed",
216
+ "the phase cannot start",
217
+ remediation="install the claude optional dependency",
218
+ ) from error
219
+ workspace = workspace.resolve(strict=True)
220
+ tools = ["Read", "Grep", "Glob"]
221
+ if not read_only:
222
+ tools += ["Edit", "Write"]
223
+
224
+ async def guard_tool(
225
+ hook_input: HookInput, _tool_use_id: str | None, _context: HookContext
226
+ ) -> SyncHookJSONOutput:
227
+ name = hook_input.get("tool_name")
228
+ params = hook_input.get("tool_input", {})
229
+ key = "file_path" if name in {"Read", "Edit", "Write"} else "path"
230
+ path = params.get(key) if isinstance(params, dict) else None
231
+ permitted = name in tools and (
232
+ (name in {"Grep", "Glob"} and path is None)
233
+ or (isinstance(path, str) and bool(path))
234
+ )
235
+ if permitted and path is not None:
236
+ target = (workspace / path).resolve()
237
+ permitted = target == workspace or workspace in target.parents
238
+ if name in {"Read", "Grep", "Glob", "Edit", "Write"}:
239
+ events.tool(
240
+ str(name).lower(),
241
+ decision="allow" if permitted else "deny",
242
+ outcome="unknown" if permitted else "denied",
243
+ source="pre_tool_hook",
244
+ )
245
+ if permitted:
246
+ return {
247
+ "hookSpecificOutput": {
248
+ "hookEventName": "PreToolUse",
249
+ "permissionDecision": "allow",
250
+ }
251
+ }
252
+ return {
253
+ "hookSpecificOutput": {
254
+ "hookEventName": "PreToolUse",
255
+ "permissionDecision": "deny",
256
+ "permissionDecisionReason": "repository path and tool policy",
257
+ }
258
+ }
259
+
260
+ options = ClaudeAgentOptions(
261
+ cwd=str(workspace),
262
+ model=self.model,
263
+ fallback_model=None,
264
+ tools=tools,
265
+ allowed_tools=tools,
266
+ disallowed_tools=["Bash", "NotebookEdit", "Task"]
267
+ + ([] if not read_only else ["Edit", "Write"]),
268
+ permission_mode="dontAsk",
269
+ setting_sources=[],
270
+ mcp_servers={},
271
+ strict_mcp_config=True,
272
+ hooks={
273
+ "PreToolUse": [HookMatcher(matcher="Read|Grep|Glob|Edit|Write", hooks=[guard_tool])]
274
+ },
275
+ max_turns=20 if read_only else 40,
276
+ env=sanitized_claude_env(self.environ),
277
+ output_format={"type": "json_schema", "schema": output.model_json_schema()},
278
+ )
279
+ events.started()
280
+
281
+ async def run() -> tuple[Any, str]:
282
+ async with ClaudeSDKClient(options=options) as client:
283
+ stop_status: RunStatus | None = None
284
+
285
+ async def watch_stop() -> None:
286
+ nonlocal stop_status
287
+ assert self.stop_requested is not None
288
+ while True:
289
+ status = self.stop_requested()
290
+ if status is not None:
291
+ stop_status = status
292
+ await client.interrupt()
293
+ return
294
+ await asyncio.sleep(0.2)
295
+
296
+ watcher = (
297
+ asyncio.create_task(watch_stop()) if self.stop_requested is not None else None
298
+ )
299
+ try:
300
+ await client.query(prompt)
301
+ async for message in client.receive_response():
302
+ if watcher is not None and watcher.done():
303
+ watcher.result()
304
+ if stop_status is not None:
305
+ raise RunStopped(stop_status.value)
306
+ if isinstance(message, ResultMessage):
307
+ claude_usage(
308
+ events,
309
+ getattr(message, "usage", None),
310
+ getattr(message, "total_cost_usd", None),
311
+ )
312
+ denials = getattr(message, "permission_denials", None)
313
+ if isinstance(denials, list) and denials:
314
+ events.permission_denials(len(denials))
315
+ if message.is_error:
316
+ raise_claude_result_error(message.result)
317
+ return (
318
+ message.structured_output
319
+ if message.structured_output is not None
320
+ else message.result,
321
+ message.session_id,
322
+ )
323
+ if watcher is not None and watcher.done():
324
+ watcher.result()
325
+ if stop_status is not None:
326
+ raise RunStopped(stop_status.value)
327
+ finally:
328
+ if watcher is not None:
329
+ watcher.cancel()
330
+ with suppress(asyncio.CancelledError):
331
+ await watcher
332
+ raise CohorteError(
333
+ ErrorCode.OUTPUT_INVALID,
334
+ "Claude turn ended without a result",
335
+ "the workflow phase was rejected",
336
+ )
337
+
338
+ try:
339
+ raw, session_ref = asyncio.run(run())
340
+ try:
341
+ # SDK structured_output is a dict; JSON mode accepts wire enums.
342
+ value = output.model_validate_json(raw if isinstance(raw, str) else json.dumps(raw))
343
+ except (ValueError, TypeError) as error:
344
+ raise CohorteError(
345
+ ErrorCode.OUTPUT_INVALID,
346
+ f"Claude returned invalid structured output for {output.__name__}",
347
+ "the workflow phase was rejected",
348
+ remediation="inspect provider diagnostics and retry the phase",
349
+ details={"validation_error": str(error)[:2000]},
350
+ ) from error
351
+ except RunStopped:
352
+ events.stopped()
353
+ raise
354
+ except Exception as error:
355
+ events.failed(error)
356
+ raise
357
+ events.finished(session_ref)
358
+ return value, session_ref
359
+
360
+ def _structured_turn(
361
+ self,
362
+ workspace: Path,
363
+ prompt: str,
364
+ output: type[StructuredOutput],
365
+ read_only: bool,
366
+ phase: str,
367
+ ) -> StructuredOutput:
368
+ return self._structured_turn_with_session(workspace, prompt, output, read_only, phase)[0]
369
+
370
+ def brainstorm_perspective(
371
+ self, workspace: Path, prompt: str, perspective: str
372
+ ) -> BrainstormPerspectiveTurn:
373
+ contribution, session_ref = self._structured_turn_with_session(
374
+ workspace, prompt, BrainstormContribution, True, "brainstorm_perspective"
375
+ )
376
+ return BrainstormPerspectiveTurn(
377
+ session_ref=session_ref,
378
+ contribution=contribution.model_copy(
379
+ update={"contribution_id": perspective, "perspective": perspective}
380
+ ),
381
+ )
382
+
383
+ def brainstorm_synthesis(self, workspace: Path, prompt: str) -> BrainstormSynthesisTurn:
384
+ synthesis, session_ref = self._structured_turn_with_session(
385
+ workspace, prompt, BrainstormSynthesis, True, "brainstorm_synthesis"
386
+ )
387
+ return BrainstormSynthesisTurn(session_ref=session_ref, synthesis=synthesis)
388
+
389
+ def build(self, workspace: Path, prompt: str) -> AgentReport:
390
+ return self._structured_turn(workspace, prompt, AgentReport, False, "build")
391
+
392
+ def review(self, workspace: Path, prompt: str) -> AgentReview:
393
+ return self._structured_turn(workspace, prompt, AgentReview, True, "review")
394
+
395
+ def fix(self, workspace: Path, prompt: str) -> AgentReport:
396
+ return self._structured_turn(workspace, prompt, AgentReport, False, "fix")
397
+
398
+ def verify_live(self) -> dict[str, Any]:
399
+ status = self._require_subscription()
400
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, ResultMessage
401
+
402
+ async def run() -> str | None:
403
+ options = ClaudeAgentOptions(
404
+ cwd=str(self.cwd),
405
+ tools=[],
406
+ allowed_tools=[],
407
+ disallowed_tools=["Bash", "Edit", "Write", "NotebookEdit", "Task"],
408
+ permission_mode="dontAsk",
409
+ setting_sources=[],
410
+ mcp_servers={},
411
+ strict_mcp_config=True,
412
+ env=sanitized_claude_env(self.environ),
413
+ max_turns=1,
414
+ )
415
+ async with ClaudeSDKClient(options=options) as client:
416
+ await client.query("Reply with exactly G0_CLAUDE_OK. Do not use any tool.")
417
+ async for message in client.receive_response():
418
+ if isinstance(message, ResultMessage):
419
+ if message.is_error:
420
+ raise_claude_result_error(message.result)
421
+ return message.result
422
+ return None
423
+
424
+ if (asyncio.run(run()) or "").strip() != "G0_CLAUDE_OK":
425
+ raise CohorteError(
426
+ ErrorCode.OUTPUT_INVALID,
427
+ "Claude live probe returned an unexpected response",
428
+ "the account/runtime combination is not verified",
429
+ )
430
+ return {
431
+ "provider": "claude",
432
+ "account_id": status.account_id,
433
+ "effective_auth_mode": status.effective_auth_mode.value,
434
+ "billing_evidence": status.billing_evidence.value,
435
+ "runtime_version": status.runtime_version,
436
+ "probe": "passed",
437
+ "checked_at": datetime.now(UTC).isoformat(),
438
+ }