enginery 0.1.0__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.
- enginery/__init__.py +14 -0
- enginery/adapters/__init__.py +10 -0
- enginery/adapters/armory.py +243 -0
- enginery/adapters/claude_code.py +371 -0
- enginery/adapters/github.py +885 -0
- enginery/adapters/local.py +432 -0
- enginery/adapters/omp.py +343 -0
- enginery/adapters/pypi.py +199 -0
- enginery/application/__init__.py +8 -0
- enginery/application/adapter_types.py +199 -0
- enginery/application/delivery_ports.py +219 -0
- enginery/application/work_ports.py +403 -0
- enginery/capabilities/__init__.py +11 -0
- enginery/capabilities/approval.py +69 -0
- enginery/capabilities/errors.py +63 -0
- enginery/capabilities/lock.py +129 -0
- enginery/capabilities/materialize.py +99 -0
- enginery/capabilities/resolver.py +205 -0
- enginery/capabilities/serialization.py +113 -0
- enginery/capabilities/signature.py +120 -0
- enginery/cli/__init__.py +7 -0
- enginery/cli/_exit_codes.py +38 -0
- enginery/cli/capability.py +78 -0
- enginery/cli/doctor.py +61 -0
- enginery/cli/ledger.py +43 -0
- enginery/cli/main.py +396 -0
- enginery/cli/outcome.py +169 -0
- enginery/cli/stage1.py +345 -0
- enginery/cli/stage2.py +71 -0
- enginery/domain/__init__.py +8 -0
- enginery/domain/artifact.py +72 -0
- enginery/domain/digests.py +62 -0
- enginery/domain/enums.py +32 -0
- enginery/domain/errors.py +129 -0
- enginery/domain/evidence.py +75 -0
- enginery/domain/factory_change.py +120 -0
- enginery/domain/ids.py +182 -0
- enginery/domain/immutable.py +60 -0
- enginery/domain/intervention.py +50 -0
- enginery/domain/node_attempt.py +206 -0
- enginery/domain/observation.py +120 -0
- enginery/domain/outcome.py +83 -0
- enginery/domain/plan_execution.py +275 -0
- enginery/domain/policy_decision.py +145 -0
- enginery/domain/principal.py +20 -0
- enginery/domain/run.py +174 -0
- enginery/domain/serialization.py +766 -0
- enginery/domain/stack.py +387 -0
- enginery/domain/state_machine.py +85 -0
- enginery/domain/work_item.py +145 -0
- enginery/domain/workflow/__init__.py +11 -0
- enginery/domain/workflow/budget.py +59 -0
- enginery/domain/workflow/manifest.py +310 -0
- enginery/domain/workflow/node.py +331 -0
- enginery/domain/workflow/schema.py +96 -0
- enginery/engine/__init__.py +8 -0
- enginery/engine/coordinator.py +343 -0
- enginery/engine/fixture_build.py +175 -0
- enginery/engine/leases.py +335 -0
- enginery/engine/omp_worker.py +115 -0
- enginery/engine/plan_execution.py +388 -0
- enginery/engine/recovery.py +185 -0
- enginery/engine/release_manifest.py +194 -0
- enginery/engine/results.py +44 -0
- enginery/engine/runtime.py +1158 -0
- enginery/engine/scheduler.py +199 -0
- enginery/engine/stack_coordinator.py +205 -0
- enginery/engine/supervisor.py +447 -0
- enginery/engine/workspace.py +409 -0
- enginery/evaluation/__init__.py +8 -0
- enginery/evaluation/outcomes.py +380 -0
- enginery/evaluation/queries.py +106 -0
- enginery/evidence/__init__.py +31 -0
- enginery/evidence/evaluator.py +128 -0
- enginery/evidence/terminal.py +307 -0
- enginery/ledger/__init__.py +69 -0
- enginery/ledger/artifact_store.py +130 -0
- enginery/ledger/artifacts.py +152 -0
- enginery/ledger/backup.py +137 -0
- enginery/ledger/commit_cursors.py +65 -0
- enginery/ledger/connection.py +69 -0
- enginery/ledger/errors.py +64 -0
- enginery/ledger/events.py +302 -0
- enginery/ledger/inbox.py +192 -0
- enginery/ledger/leases.py +142 -0
- enginery/ledger/migrations.py +68 -0
- enginery/ledger/outbox.py +90 -0
- enginery/ledger/process_manager.py +142 -0
- enginery/ledger/projections.py +222 -0
- enginery/ledger/redaction.py +105 -0
- enginery/ledger/schema.py +196 -0
- enginery/ledger/service.py +173 -0
- enginery/ledger/verify.py +120 -0
- enginery/plans/__init__.py +15 -0
- enginery/plans/loader.py +41 -0
- enginery/plans/model.py +313 -0
- enginery/plans/normalization.py +59 -0
- enginery/policy/__init__.py +20 -0
- enginery/policy/approval.py +212 -0
- enginery/policy/defaults.py +67 -0
- enginery/policy/evaluator.py +178 -0
- enginery/policy/rules.py +175 -0
- enginery/policy/schemas.py +195 -0
- enginery/py.typed +0 -0
- enginery/workflows/evidence.py +66 -0
- enginery/workflows/implementation.py +124 -0
- enginery/workflows/issue_to_pr.py +153 -0
- enginery/workflows/merge_policy.py +166 -0
- enginery/workflows/plan_to_release.py +206 -0
- enginery/workflows/pull_request.py +73 -0
- enginery/workflows/review.py +68 -0
- enginery/workflows/stage1.py +1284 -0
- enginery/workflows/stage1_runtime.py +222 -0
- enginery/workflows/verification.py +139 -0
- enginery-0.1.0.dist-info/METADATA +74 -0
- enginery-0.1.0.dist-info/RECORD +119 -0
- enginery-0.1.0.dist-info/WHEEL +4 -0
- enginery-0.1.0.dist-info/entry_points.txt +2 -0
- enginery-0.1.0.dist-info/licenses/LICENSE +201 -0
enginery/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Enginery: the control plane that turns engineering intent into verified outcomes.
|
|
2
|
+
|
|
3
|
+
``__version__`` is read from installed package metadata rather than hardcoded
|
|
4
|
+
here, so ``pyproject.toml`` remains the single canonical source for the
|
|
5
|
+
project's version.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib import metadata
|
|
11
|
+
|
|
12
|
+
__version__ = metadata.version(__name__)
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Provider implementations: work ledgers, harnesses, workspaces, SCM, CI,
|
|
2
|
+
release, and capability registries.
|
|
3
|
+
|
|
4
|
+
Rule: ``adapters`` may import ``domain``,
|
|
5
|
+
``application``, ``engine``, ``ledger``, ``policy``, ``evidence``, and
|
|
6
|
+
``evaluation``. Provider SDK objects never cross this boundary into inner
|
|
7
|
+
layers. ``adapters`` must not import ``cli``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Optional Armory capability-registry adapter.
|
|
2
|
+
|
|
3
|
+
Armory (`Mathews-Tom/armory`) is an external, GitHub-hosted catalog: a
|
|
4
|
+
single ``manifest.yaml`` fetched over HTTPS, with no MCP server, no
|
|
5
|
+
per-package signature, and no checksum field of its own. Content
|
|
6
|
+
addressing here comes entirely from this adapter's own fetch-and-hash
|
|
7
|
+
step, never from anything Armory asserts.
|
|
8
|
+
|
|
9
|
+
Armory is never a runtime dependency of the engine: importing this module
|
|
10
|
+
needs only the standard library, and every method that reaches the
|
|
11
|
+
network requires the optional ``armory`` extra (``pyyaml``) -- ``probe()``
|
|
12
|
+
reports ``AdapterAvailability.UNAVAILABLE`` when it is missing, exactly
|
|
13
|
+
like the OMP and Claude Code adapters report an absent CLI, so the engine
|
|
14
|
+
keeps working with Armory disabled.
|
|
15
|
+
|
|
16
|
+
Every capability this adapter returns reports ``provenance="armory"``,
|
|
17
|
+
which ``CapabilityResolver`` classifies as ``unauthenticated`` for any
|
|
18
|
+
run-introduced use unless a separate signature verifies -- Armory
|
|
19
|
+
supplies none today, so every Armory-sourced capability a run introduces
|
|
20
|
+
requires interactive exact-digest human approval before it can execute.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from enginery.application.adapter_types import (
|
|
31
|
+
ADAPTER_API_VERSION,
|
|
32
|
+
AdapterAvailability,
|
|
33
|
+
AdapterCapability,
|
|
34
|
+
AdapterFingerprint,
|
|
35
|
+
AdapterStatus,
|
|
36
|
+
ProviderKind,
|
|
37
|
+
)
|
|
38
|
+
from enginery.application.delivery_ports import CapabilityDescriptor
|
|
39
|
+
from enginery.domain.digests import Digest
|
|
40
|
+
from enginery.domain.errors import MissingPrerequisiteError, TransientProviderFailureError
|
|
41
|
+
|
|
42
|
+
_DEFAULT_MANIFEST_URL = "https://raw.githubusercontent.com/Mathews-Tom/armory/main/manifest.yaml"
|
|
43
|
+
_DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
44
|
+
_PROVENANCE_LABEL = "armory"
|
|
45
|
+
_PACKAGE_SECTIONS = ("skills", "agents", "hooks", "rules", "commands", "utilities", "presets")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class ArmoryPackageEntry:
|
|
50
|
+
"""One catalog row from ``manifest.yaml``."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
version: str
|
|
54
|
+
kind: str
|
|
55
|
+
source: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _default_fetcher(url: str, timeout: float) -> bytes:
|
|
59
|
+
request = urllib.request.Request(url, headers={"User-Agent": "enginery-armory-adapter"})
|
|
60
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
61
|
+
return bytes(response.read())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _raw_content_url(blob_url: str) -> str:
|
|
65
|
+
"""Turn a ``github.com/.../blob/<ref>/<path>`` URL into a raw-content URL."""
|
|
66
|
+
|
|
67
|
+
marker = "github.com/"
|
|
68
|
+
if marker not in blob_url or "/blob/" not in blob_url:
|
|
69
|
+
raise MissingPrerequisiteError(
|
|
70
|
+
"Armory manifest source is not a recognized GitHub blob URL",
|
|
71
|
+
details={"source": blob_url},
|
|
72
|
+
)
|
|
73
|
+
rest = blob_url.split(marker, 1)[1]
|
|
74
|
+
parts = rest.split("/", 3)
|
|
75
|
+
if len(parts) != 4 or parts[2] != "blob":
|
|
76
|
+
raise MissingPrerequisiteError(
|
|
77
|
+
"Armory manifest source is not a recognized GitHub blob URL",
|
|
78
|
+
details={"source": blob_url},
|
|
79
|
+
)
|
|
80
|
+
owner, repo, _blob, ref_and_path = parts
|
|
81
|
+
return f"https://raw.githubusercontent.com/{owner}/{repo}/{ref_and_path}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _license_url(manifest_url: str) -> str | None:
|
|
85
|
+
suffix = "manifest.yaml"
|
|
86
|
+
if not manifest_url.endswith(suffix):
|
|
87
|
+
return None
|
|
88
|
+
return manifest_url[: -len(suffix)] + "LICENSE"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _detect_spdx_license(text: str) -> str:
|
|
92
|
+
head = text[:400]
|
|
93
|
+
if "MIT License" in head:
|
|
94
|
+
return "MIT"
|
|
95
|
+
if "Apache License" in head and "Version 2.0" in head:
|
|
96
|
+
return "Apache-2.0"
|
|
97
|
+
return "unknown"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_manifest(document: object) -> tuple[ArmoryPackageEntry, ...]:
|
|
101
|
+
if not isinstance(document, dict):
|
|
102
|
+
raise MissingPrerequisiteError("Armory manifest root must be a mapping")
|
|
103
|
+
packages = document.get("packages")
|
|
104
|
+
if not isinstance(packages, dict):
|
|
105
|
+
raise MissingPrerequisiteError("Armory manifest is missing a 'packages' mapping")
|
|
106
|
+
entries: list[ArmoryPackageEntry] = []
|
|
107
|
+
for kind, items in packages.items():
|
|
108
|
+
if kind not in _PACKAGE_SECTIONS or not isinstance(items, list):
|
|
109
|
+
continue
|
|
110
|
+
for item in items:
|
|
111
|
+
if not isinstance(item, dict):
|
|
112
|
+
continue
|
|
113
|
+
name, version, source = item.get("name"), item.get("version"), item.get("source")
|
|
114
|
+
if not isinstance(name, str) or not name.strip():
|
|
115
|
+
continue
|
|
116
|
+
if not isinstance(source, str) or not source.strip():
|
|
117
|
+
continue
|
|
118
|
+
version_text = str(version).strip() if version is not None else ""
|
|
119
|
+
if not version_text:
|
|
120
|
+
continue
|
|
121
|
+
entries.append(
|
|
122
|
+
ArmoryPackageEntry(name=name, version=version_text, kind=str(kind), source=source)
|
|
123
|
+
)
|
|
124
|
+
return tuple(entries)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ArmoryCapabilitySource:
|
|
128
|
+
"""Discover, resolve, and fetch capabilities from the Armory catalog."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
manifest_url: str = _DEFAULT_MANIFEST_URL,
|
|
134
|
+
timeout_seconds: float = _DEFAULT_TIMEOUT_SECONDS,
|
|
135
|
+
fetcher: Callable[[str, float], bytes] = _default_fetcher,
|
|
136
|
+
) -> None:
|
|
137
|
+
self._manifest_url = manifest_url
|
|
138
|
+
self._timeout_seconds = timeout_seconds
|
|
139
|
+
self._fetcher = fetcher
|
|
140
|
+
self._license_cache: str | None = None
|
|
141
|
+
|
|
142
|
+
def probe(self) -> AdapterStatus:
|
|
143
|
+
try:
|
|
144
|
+
import yaml # noqa: F401 -- see module docstring: optional `armory` extra
|
|
145
|
+
except ImportError:
|
|
146
|
+
return AdapterStatus(
|
|
147
|
+
kind=ProviderKind.CAPABILITY_SOURCE,
|
|
148
|
+
availability=AdapterAvailability.UNAVAILABLE,
|
|
149
|
+
fingerprint=None,
|
|
150
|
+
detail="pyyaml is not installed; run `uv sync --extra armory` to enable Armory",
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
self._entries()
|
|
154
|
+
except (TransientProviderFailureError, MissingPrerequisiteError) as error:
|
|
155
|
+
return AdapterStatus(
|
|
156
|
+
kind=ProviderKind.CAPABILITY_SOURCE,
|
|
157
|
+
availability=AdapterAvailability.MISCONFIGURED,
|
|
158
|
+
fingerprint=None,
|
|
159
|
+
detail=str(error),
|
|
160
|
+
)
|
|
161
|
+
return AdapterStatus(
|
|
162
|
+
kind=ProviderKind.CAPABILITY_SOURCE,
|
|
163
|
+
availability=AdapterAvailability.AVAILABLE,
|
|
164
|
+
fingerprint=AdapterFingerprint(
|
|
165
|
+
provider_id="armory-capability-source",
|
|
166
|
+
provider_version="1.0.0",
|
|
167
|
+
api_version=ADAPTER_API_VERSION,
|
|
168
|
+
capabilities=(
|
|
169
|
+
AdapterCapability(name="discover", version=1),
|
|
170
|
+
AdapterCapability(name="resolve", version=1),
|
|
171
|
+
AdapterCapability(name="fetch", version=1),
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
detail=f"armory manifest reachable at {self._manifest_url}",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def discover(self) -> tuple[CapabilityDescriptor, ...]:
|
|
178
|
+
return tuple(self._descriptor(entry) for entry in self._entries())
|
|
179
|
+
|
|
180
|
+
def resolve(self, name: str, version: str) -> CapabilityDescriptor | None:
|
|
181
|
+
entry = self._find(name, version)
|
|
182
|
+
return None if entry is None else self._descriptor(entry)
|
|
183
|
+
|
|
184
|
+
def fetch(self, name: str, version: str) -> bytes:
|
|
185
|
+
entry = self._find(name, version)
|
|
186
|
+
if entry is None:
|
|
187
|
+
raise MissingPrerequisiteError(
|
|
188
|
+
"capability was not found in the Armory manifest",
|
|
189
|
+
details={"name": name, "version": version},
|
|
190
|
+
)
|
|
191
|
+
return self._fetch_bytes(_raw_content_url(entry.source))
|
|
192
|
+
|
|
193
|
+
def _find(self, name: str, version: str) -> ArmoryPackageEntry | None:
|
|
194
|
+
return next(
|
|
195
|
+
(item for item in self._entries() if item.name == name and item.version == version),
|
|
196
|
+
None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _descriptor(self, entry: ArmoryPackageEntry) -> CapabilityDescriptor:
|
|
200
|
+
content = self._fetch_bytes(_raw_content_url(entry.source))
|
|
201
|
+
return CapabilityDescriptor(
|
|
202
|
+
name=entry.name,
|
|
203
|
+
version=entry.version,
|
|
204
|
+
digest=Digest.of_bytes(content),
|
|
205
|
+
provenance=_PROVENANCE_LABEL,
|
|
206
|
+
license=self._license(),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _license(self) -> str:
|
|
210
|
+
if self._license_cache is not None:
|
|
211
|
+
return self._license_cache
|
|
212
|
+
url = _license_url(self._manifest_url)
|
|
213
|
+
if url is None:
|
|
214
|
+
self._license_cache = "unknown"
|
|
215
|
+
return self._license_cache
|
|
216
|
+
try:
|
|
217
|
+
text = self._fetch_bytes(url).decode("utf-8", errors="replace")
|
|
218
|
+
except TransientProviderFailureError:
|
|
219
|
+
self._license_cache = "unknown"
|
|
220
|
+
else:
|
|
221
|
+
self._license_cache = _detect_spdx_license(text)
|
|
222
|
+
return self._license_cache
|
|
223
|
+
|
|
224
|
+
def _fetch_bytes(self, url: str) -> bytes:
|
|
225
|
+
try:
|
|
226
|
+
return self._fetcher(url, self._timeout_seconds)
|
|
227
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
228
|
+
raise TransientProviderFailureError(
|
|
229
|
+
"failed to fetch Armory content", details={"url": url}
|
|
230
|
+
) from error
|
|
231
|
+
|
|
232
|
+
def _entries(self) -> tuple[ArmoryPackageEntry, ...]:
|
|
233
|
+
import yaml
|
|
234
|
+
|
|
235
|
+
raw = self._fetch_bytes(self._manifest_url)
|
|
236
|
+
try:
|
|
237
|
+
document = yaml.safe_load(raw)
|
|
238
|
+
except yaml.YAMLError as error:
|
|
239
|
+
raise MissingPrerequisiteError("Armory manifest is not valid YAML") from error
|
|
240
|
+
return _parse_manifest(document)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
__all__ = ["ArmoryCapabilitySource", "ArmoryPackageEntry"]
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Claude Code CLI harness adapter with versioned stream-json event normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol, cast
|
|
16
|
+
|
|
17
|
+
from enginery.application.adapter_types import (
|
|
18
|
+
ADAPTER_API_VERSION,
|
|
19
|
+
AdapterAvailability,
|
|
20
|
+
AdapterCapability,
|
|
21
|
+
AdapterEventKind,
|
|
22
|
+
AdapterFingerprint,
|
|
23
|
+
AdapterStatus,
|
|
24
|
+
NormalizedAdapterEvent,
|
|
25
|
+
ProviderKind,
|
|
26
|
+
)
|
|
27
|
+
from enginery.application.work_ports import (
|
|
28
|
+
HarnessOutput,
|
|
29
|
+
HarnessResult,
|
|
30
|
+
HarnessSession,
|
|
31
|
+
HarnessTask,
|
|
32
|
+
)
|
|
33
|
+
from enginery.domain.artifact import RedactionClassification
|
|
34
|
+
from enginery.domain.errors import CancellationError, InvalidInputError, WorkerFailureError
|
|
35
|
+
from enginery.domain.ids import OperationId
|
|
36
|
+
from enginery.domain.node_attempt import ReconciliationResult
|
|
37
|
+
from enginery.ledger.artifact_store import ArtifactStore
|
|
38
|
+
from enginery.ledger.redaction import redact_credential_shaped_text
|
|
39
|
+
|
|
40
|
+
_CLAUDE_CODE_EVENT_SCHEMA_VERSION = 1
|
|
41
|
+
_KNOWN_EVENT_TYPES = frozenset({"system", "assistant", "user", "rate_limit_event", "result"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _ClaudeCodeProcess(Protocol):
|
|
45
|
+
pid: int
|
|
46
|
+
returncode: int | None
|
|
47
|
+
|
|
48
|
+
def communicate(self) -> tuple[str, str | None]: ...
|
|
49
|
+
|
|
50
|
+
def poll(self) -> int | None: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ClaudeCodeAdapterConfig:
|
|
55
|
+
"""Opaque Claude Code CLI configuration without credential material.
|
|
56
|
+
|
|
57
|
+
``model`` is an optional override; when blank, no ``--model`` flag is
|
|
58
|
+
passed and Claude Code resolves its own configured default. The
|
|
59
|
+
actually-selected model is read back from harness output, never assumed.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
credential_reference: str
|
|
63
|
+
executable: str = "claude"
|
|
64
|
+
model: str = ""
|
|
65
|
+
event_schema_version: int = _CLAUDE_CODE_EVENT_SCHEMA_VERSION
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
if not self.credential_reference.strip():
|
|
69
|
+
raise InvalidInputError("Claude Code credential reference must be non-blank")
|
|
70
|
+
if not self.executable.strip():
|
|
71
|
+
raise InvalidInputError("Claude Code executable must be non-blank")
|
|
72
|
+
if self.event_schema_version != _CLAUDE_CODE_EVENT_SCHEMA_VERSION:
|
|
73
|
+
raise InvalidInputError("Claude Code event schema version is unsupported")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True)
|
|
77
|
+
class ClaudeCodeHarness:
|
|
78
|
+
"""Run Claude Code headless and retain only redacted output artifacts.
|
|
79
|
+
|
|
80
|
+
Every launched command is scoped with ``--strict-mcp-config`` so a Stage 1
|
|
81
|
+
worker task never inherits the operator's ambient MCP servers. ``--bare``
|
|
82
|
+
is deliberately not used: it forces API-key-only authentication and
|
|
83
|
+
rejects the OAuth/keychain credentials most operators already have
|
|
84
|
+
configured via ``claude login``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
config: ClaudeCodeAdapterConfig
|
|
88
|
+
artifact_store: ArtifactStore
|
|
89
|
+
command_runner: Callable[[tuple[str, ...]], subprocess.CompletedProcess[str]] = field(
|
|
90
|
+
default=lambda arguments: subprocess.run(
|
|
91
|
+
arguments, check=False, capture_output=True, text=True
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
process_factory: Callable[[tuple[str, ...], Path], _ClaudeCodeProcess] = field(
|
|
95
|
+
default=lambda arguments, workspace: subprocess.Popen(
|
|
96
|
+
arguments,
|
|
97
|
+
cwd=workspace,
|
|
98
|
+
stdout=subprocess.PIPE,
|
|
99
|
+
stderr=subprocess.STDOUT,
|
|
100
|
+
stdin=subprocess.DEVNULL,
|
|
101
|
+
text=True,
|
|
102
|
+
start_new_session=True,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
terminator: Callable[[int], None] = field(default=lambda pid: os.killpg(pid, signal.SIGTERM))
|
|
106
|
+
_sessions: dict[str, HarnessSession] = field(default_factory=dict, init=False)
|
|
107
|
+
_processes: dict[str, _ClaudeCodeProcess] = field(default_factory=dict, init=False)
|
|
108
|
+
_collected: dict[str, tuple[tuple[NormalizedAdapterEvent, ...], HarnessResult]] = field(
|
|
109
|
+
default_factory=dict, init=False
|
|
110
|
+
)
|
|
111
|
+
_outcomes: dict[str, ReconciliationResult] = field(default_factory=dict, init=False)
|
|
112
|
+
_cancelled: set[str] = field(default_factory=set, init=False)
|
|
113
|
+
|
|
114
|
+
def probe(self) -> AdapterStatus:
|
|
115
|
+
try:
|
|
116
|
+
completed = self.command_runner((self.config.executable, "--version"))
|
|
117
|
+
except OSError:
|
|
118
|
+
return AdapterStatus(
|
|
119
|
+
kind=ProviderKind.HARNESS,
|
|
120
|
+
availability=AdapterAvailability.UNAVAILABLE,
|
|
121
|
+
fingerprint=None,
|
|
122
|
+
detail="Claude Code CLI is unavailable",
|
|
123
|
+
)
|
|
124
|
+
version = completed.stdout.strip()
|
|
125
|
+
if completed.returncode != 0 or "Claude Code" not in version:
|
|
126
|
+
return AdapterStatus(
|
|
127
|
+
kind=ProviderKind.HARNESS,
|
|
128
|
+
availability=AdapterAvailability.MISCONFIGURED,
|
|
129
|
+
fingerprint=None,
|
|
130
|
+
detail="Claude Code CLI did not report a compatible version",
|
|
131
|
+
)
|
|
132
|
+
return AdapterStatus(
|
|
133
|
+
kind=ProviderKind.HARNESS,
|
|
134
|
+
availability=AdapterAvailability.AVAILABLE,
|
|
135
|
+
fingerprint=AdapterFingerprint(
|
|
136
|
+
provider_id="claude-code-harness",
|
|
137
|
+
provider_version=(
|
|
138
|
+
f"{version};stream-json-schema={self.config.event_schema_version}"
|
|
139
|
+
),
|
|
140
|
+
api_version=ADAPTER_API_VERSION,
|
|
141
|
+
capabilities=(
|
|
142
|
+
AdapterCapability("stream_json_events", self.config.event_schema_version),
|
|
143
|
+
AdapterCapability("cancellation", 1),
|
|
144
|
+
AdapterCapability("redacted_artifacts", 1),
|
|
145
|
+
AdapterCapability("structured_output", 1),
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
detail="Claude Code harness is available",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def start(self, task: HarnessTask) -> HarnessSession:
|
|
152
|
+
session = HarnessSession(
|
|
153
|
+
session_id=f"claude-code-{task.operation_id}", operation_id=task.operation_id
|
|
154
|
+
)
|
|
155
|
+
if session.session_id in self._sessions:
|
|
156
|
+
raise WorkerFailureError("Claude Code harness session already exists")
|
|
157
|
+
process = self.process_factory(self.command_for(task), task.workspace_path)
|
|
158
|
+
self._sessions[session.session_id] = session
|
|
159
|
+
self._processes[session.session_id] = process
|
|
160
|
+
self._outcomes[str(task.operation_id)] = ReconciliationResult.FOUND_MATCHING
|
|
161
|
+
return session
|
|
162
|
+
|
|
163
|
+
def events(self, session: HarnessSession) -> Iterator[NormalizedAdapterEvent]:
|
|
164
|
+
return iter(self._collect(session)[0])
|
|
165
|
+
|
|
166
|
+
def result(self, session: HarnessSession) -> HarnessResult:
|
|
167
|
+
return self._collect(session)[1]
|
|
168
|
+
|
|
169
|
+
def cancel(self, session: HarnessSession, *, operation_id: OperationId) -> ReconciliationResult:
|
|
170
|
+
self._require_session(session)
|
|
171
|
+
process = self._processes[session.session_id]
|
|
172
|
+
if process.poll() is None:
|
|
173
|
+
self.terminator(process.pid)
|
|
174
|
+
self._cancelled.add(session.session_id)
|
|
175
|
+
self._outcomes[str(operation_id)] = ReconciliationResult.FOUND_MATCHING
|
|
176
|
+
return ReconciliationResult.FOUND_MATCHING
|
|
177
|
+
|
|
178
|
+
def reconcile(self, *, operation_id: OperationId) -> ReconciliationResult:
|
|
179
|
+
return self._outcomes.get(str(operation_id), ReconciliationResult.NOT_FOUND)
|
|
180
|
+
|
|
181
|
+
def _collect(
|
|
182
|
+
self, session: HarnessSession
|
|
183
|
+
) -> tuple[tuple[NormalizedAdapterEvent, ...], HarnessResult]:
|
|
184
|
+
self._require_session(session)
|
|
185
|
+
cached = self._collected.get(session.session_id)
|
|
186
|
+
if cached is not None:
|
|
187
|
+
return cached
|
|
188
|
+
process = self._processes[session.session_id]
|
|
189
|
+
output, _ = process.communicate()
|
|
190
|
+
redacted = redact_credential_shaped_text(output)
|
|
191
|
+
digest = self.artifact_store.publish_bytes(redacted.encode(), media_type="application/json")
|
|
192
|
+
events, terminal_status = _normalize_events(redacted, session.operation_id)
|
|
193
|
+
status = "cancelled" if session.session_id in self._cancelled else terminal_status
|
|
194
|
+
result = HarnessResult(
|
|
195
|
+
session_id=session.session_id,
|
|
196
|
+
terminal_status=status,
|
|
197
|
+
outputs=(HarnessOutput(digest=digest, redaction=RedactionClassification.SENSITIVE),),
|
|
198
|
+
)
|
|
199
|
+
collected = (events, result)
|
|
200
|
+
self._collected[session.session_id] = collected
|
|
201
|
+
return collected
|
|
202
|
+
|
|
203
|
+
def command_for(self, task: HarnessTask) -> tuple[str, ...]:
|
|
204
|
+
arguments: list[str] = [
|
|
205
|
+
self.config.executable,
|
|
206
|
+
"-p",
|
|
207
|
+
_render_task(task),
|
|
208
|
+
"--output-format",
|
|
209
|
+
"stream-json",
|
|
210
|
+
"--verbose",
|
|
211
|
+
"--input-format",
|
|
212
|
+
"text",
|
|
213
|
+
"--no-session-persistence",
|
|
214
|
+
"--session-id",
|
|
215
|
+
str(uuid.uuid5(uuid.NAMESPACE_URL, str(task.operation_id))),
|
|
216
|
+
"--strict-mcp-config",
|
|
217
|
+
"--permission-mode",
|
|
218
|
+
"bypassPermissions",
|
|
219
|
+
]
|
|
220
|
+
if self.config.model:
|
|
221
|
+
arguments += ["--model", self.config.model]
|
|
222
|
+
return tuple(arguments)
|
|
223
|
+
|
|
224
|
+
def _require_session(self, session: HarnessSession) -> None:
|
|
225
|
+
if self._sessions.get(session.session_id) != session:
|
|
226
|
+
raise CancellationError("unknown Claude Code harness session")
|
|
227
|
+
|
|
228
|
+
def supervised_command(self, task: HarnessTask, *, result_path: Path) -> tuple[str, ...]:
|
|
229
|
+
"""Return a worker command whose parent is the coordinator supervisor.
|
|
230
|
+
|
|
231
|
+
Reuses the existing generic supervised-worker entrypoint: it only
|
|
232
|
+
runs the given command and redacts/records its exit, independent of
|
|
233
|
+
which harness CLI is being wrapped.
|
|
234
|
+
"""
|
|
235
|
+
if not result_path.is_absolute():
|
|
236
|
+
raise InvalidInputError("Claude Code worker result path must be absolute")
|
|
237
|
+
return (
|
|
238
|
+
sys.executable,
|
|
239
|
+
"-m",
|
|
240
|
+
"enginery.engine.omp_worker",
|
|
241
|
+
"--operation-id",
|
|
242
|
+
str(task.operation_id),
|
|
243
|
+
"--output",
|
|
244
|
+
str(result_path),
|
|
245
|
+
"--",
|
|
246
|
+
*self.command_for(task),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def collect_supervised(
|
|
250
|
+
self, task: HarnessTask, *, result_path: Path
|
|
251
|
+
) -> tuple[tuple[NormalizedAdapterEvent, ...], HarnessResult]:
|
|
252
|
+
"""Read a supervised worker handoff after its process exit is observed."""
|
|
253
|
+
try:
|
|
254
|
+
payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
255
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
256
|
+
raise WorkerFailureError(
|
|
257
|
+
"Claude Code supervised worker did not retain a valid result"
|
|
258
|
+
) from error
|
|
259
|
+
if not isinstance(payload, dict) or set(payload) != {
|
|
260
|
+
"operation_id",
|
|
261
|
+
"output",
|
|
262
|
+
"schema_version",
|
|
263
|
+
"terminal_status",
|
|
264
|
+
}:
|
|
265
|
+
raise WorkerFailureError("Claude Code supervised worker result has an invalid shape")
|
|
266
|
+
operation_id = payload["operation_id"]
|
|
267
|
+
output = payload["output"]
|
|
268
|
+
schema_version = payload["schema_version"]
|
|
269
|
+
terminal_status = payload["terminal_status"]
|
|
270
|
+
if (
|
|
271
|
+
not isinstance(operation_id, str)
|
|
272
|
+
or not isinstance(output, str)
|
|
273
|
+
or not isinstance(schema_version, int)
|
|
274
|
+
or not isinstance(terminal_status, str)
|
|
275
|
+
):
|
|
276
|
+
raise WorkerFailureError("Claude Code supervised worker result has invalid field types")
|
|
277
|
+
if schema_version != 1:
|
|
278
|
+
raise WorkerFailureError(
|
|
279
|
+
"Claude Code supervised worker result schema version is unsupported"
|
|
280
|
+
)
|
|
281
|
+
if operation_id != str(task.operation_id):
|
|
282
|
+
raise WorkerFailureError(
|
|
283
|
+
"Claude Code supervised worker result operation does not match task"
|
|
284
|
+
)
|
|
285
|
+
digest = self.artifact_store.publish_bytes(
|
|
286
|
+
output.encode("utf-8"), media_type="application/json"
|
|
287
|
+
)
|
|
288
|
+
events = (
|
|
289
|
+
_normalize_events(output, task.operation_id)[0]
|
|
290
|
+
if terminal_status == "succeeded"
|
|
291
|
+
else ()
|
|
292
|
+
)
|
|
293
|
+
return (
|
|
294
|
+
events,
|
|
295
|
+
HarnessResult(
|
|
296
|
+
session_id=f"claude-code-{task.operation_id}",
|
|
297
|
+
terminal_status=terminal_status,
|
|
298
|
+
outputs=(
|
|
299
|
+
HarnessOutput(digest=digest, redaction=RedactionClassification.SENSITIVE),
|
|
300
|
+
),
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _render_task(task: HarnessTask) -> str:
|
|
306
|
+
sections = (
|
|
307
|
+
("Objective", (task.objective,)),
|
|
308
|
+
("Acceptance criteria", task.acceptance_criteria),
|
|
309
|
+
("Constraints", task.constraints),
|
|
310
|
+
("Permitted capabilities", task.permitted_capabilities),
|
|
311
|
+
("Evidence requirements", task.evidence_requirements),
|
|
312
|
+
)
|
|
313
|
+
return "\n\n".join(
|
|
314
|
+
f"{heading}:\n" + "\n".join(f"- {value}" for value in values)
|
|
315
|
+
for heading, values in sections
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _normalize_events(
|
|
320
|
+
output: str, operation_id: OperationId
|
|
321
|
+
) -> tuple[tuple[NormalizedAdapterEvent, ...], str]:
|
|
322
|
+
events: list[NormalizedAdapterEvent] = []
|
|
323
|
+
terminal_status: str | None = None
|
|
324
|
+
for line in output.splitlines():
|
|
325
|
+
if not line.strip():
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
payload = cast(dict[str, object], json.loads(line))
|
|
329
|
+
except json.JSONDecodeError as error:
|
|
330
|
+
raise WorkerFailureError("Claude Code emitted malformed JSON event") from error
|
|
331
|
+
event_type = payload.get("type")
|
|
332
|
+
if not isinstance(event_type, str) or event_type not in _KNOWN_EVENT_TYPES:
|
|
333
|
+
raise WorkerFailureError("Claude Code emitted an unsupported JSON event")
|
|
334
|
+
attributes = {"event_schema_version": str(_CLAUDE_CODE_EVENT_SCHEMA_VERSION)}
|
|
335
|
+
if event_type == "system" and payload.get("subtype") == "init":
|
|
336
|
+
kind = AdapterEventKind.STARTED
|
|
337
|
+
elif event_type == "result":
|
|
338
|
+
kind = AdapterEventKind.TERMINAL
|
|
339
|
+
terminal_status = "failed" if payload.get("is_error") else "succeeded"
|
|
340
|
+
elif event_type == "rate_limit_event":
|
|
341
|
+
kind = AdapterEventKind.DIAGNOSTIC
|
|
342
|
+
else:
|
|
343
|
+
kind = AdapterEventKind.PROGRESS
|
|
344
|
+
model = _assistant_model(payload)
|
|
345
|
+
if model is not None:
|
|
346
|
+
attributes["model"] = model
|
|
347
|
+
events.append(
|
|
348
|
+
NormalizedAdapterEvent(
|
|
349
|
+
kind=kind,
|
|
350
|
+
occurred_at=datetime.now(UTC),
|
|
351
|
+
operation_id=operation_id,
|
|
352
|
+
summary=f"Claude Code {event_type}",
|
|
353
|
+
attributes=attributes,
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
if terminal_status is None:
|
|
357
|
+
raise WorkerFailureError("Claude Code output ended without a result event")
|
|
358
|
+
return tuple(events), terminal_status
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _assistant_model(payload: dict[str, object]) -> str | None:
|
|
362
|
+
if payload.get("type") != "assistant":
|
|
363
|
+
return None
|
|
364
|
+
message = payload.get("message")
|
|
365
|
+
if not isinstance(message, dict):
|
|
366
|
+
return None
|
|
367
|
+
model = message.get("model")
|
|
368
|
+
return model if isinstance(model, str) and model.strip() else None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
__all__ = ["ClaudeCodeAdapterConfig", "ClaudeCodeHarness"]
|