foundry-implementation-actor 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.
@@ -0,0 +1,193 @@
1
+ """Correlation ids — the one place this actor decides what identifies a unit of work.
2
+
3
+ WHY THIS EXISTS. Before it, exactly one call site (the engine's own `_invoke_claude`) passed
4
+ `extra={"task_id": ...}`, so the session transcript was the only thing in Loki that could be
5
+ drilled to a task. Everything else this actor logs — the framework's own `do_POST door=…` access
6
+ line, every git/buildctl step, every failure — arrived with no identity at all, which is exactly
7
+ when you most want one. This module makes carrying the ids the default rather than a thing each
8
+ call site has to remember.
9
+
10
+ TWO IDS, AND WHERE EACH COMES FROM.
11
+
12
+ * `correlation_id` — THE TRACE ID, not a second identifier invented here. One orchestration
13
+ call is one trace: the ORCHESTRATING actor's own `_call_door` injects W3C `traceparent`, and
14
+ `HttpMailbox.do_POST` extracts it into the SERVER span it opens around `Actor.receive()` — so
15
+ every actor in the pipeline, across every retry attempt, is already inside one trace before a
16
+ line of this module runs. Reading `trace.get_current_span()` and formatting its
17
+ trace id is therefore not "generating a correlation id", it is *naming the one that already
18
+ crossed the wire*. It also means a `correlation_id` in Loki pastes straight into Tempo.
19
+ A locally-minted uuid is the fallback for the no-SDK / no-active-span case only.
20
+ * `task_id` — `TASK-NNN`, off the caller's own payload. Spans retries and every peer, where the
21
+ trace id spans one orchestration run; neither subsumes the other, so both are carried.
22
+
23
+ A CALL THAT NEVER REACHES A HANDLER still gets a `correlation_id`. `Actor.receive()` refuses an
24
+ undeclared door or a schema-violating payload before any handler runs, and the binding answers an
25
+ unknown path or an unparseable body without ever calling `receive()` — so nothing of ours binds
26
+ for any of them. `CorrelationFilter` therefore falls back to the active span's trace id, which
27
+ since ADR-PASH-0005 (`papeete-actor-synchronous-messaging-http` 0.4.0) is open across every one of
28
+ those paths. That fallback is the whole reason the binding needed changing: a consumer can stamp
29
+ its own records, never the ones written on its behalf before its code ran.
30
+
31
+ WHY A HANDLER FILTER, NOT `extra=` AT EVERY CALL SITE. A `logging.Filter` on the ROOT LOGGER would
32
+ only see records logged directly to it — a record from any named logger reaches the root's
33
+ HANDLERS without ever being filtered by the root logger itself. So `install()` attaches to the
34
+ handlers, where every record does pass, including ones from code this repo does not own
35
+ (`papeete_actor_synchronous_messaging_http.mailbox`'s access log, most importantly). Nothing has
36
+ to opt in, which is the whole point: correlation ids everywhere means everywhere, not everywhere
37
+ someone remembered.
38
+
39
+ WHY `bind()` NEVER RESETS. `HttpMailbox` serves on a `ThreadingHTTPServer` — one fresh thread per
40
+ request, never pooled — and a `ContextVar` set inside a thread is invisible to every other thread
41
+ and dies with it. So request scope is thread scope here, for free, and NOT resetting is what lets
42
+ the ids reach the lines emitted *after* the handler returns: `do_POST`'s own access log sits in a
43
+ `finally` outside the span, and would otherwise be the one line in the whole request with no
44
+ identity. A second `bind()` in the same thread overwrites, which is what an attempt counter wants.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import contextvars
49
+ import json
50
+ import logging
51
+ import time
52
+ import uuid
53
+ from contextlib import contextmanager
54
+
55
+ try: # the same guard papeete-actor-synchronous-messaging-
56
+ from opentelemetry import trace # http's own _tracing.py keeps: the API is a no-op
57
+ except ImportError: # without an SDK, and absent entirely in a bare test
58
+ trace = None # environment. Neither is an error here.
59
+
60
+ _FIELDS: contextvars.ContextVar[dict] = contextvars.ContextVar("papeete_correlation", default={})
61
+
62
+ STEP_LOGGER = "pipeline"
63
+
64
+
65
+ def _trace_id() -> str | None:
66
+ """The active span's trace id, or None — no SDK, no span, or an invalid context."""
67
+ if trace is None:
68
+ return None
69
+ context = trace.get_current_span().get_span_context()
70
+ return format(context.trace_id, "032x") if context.is_valid else None
71
+
72
+
73
+ def correlation_id() -> str:
74
+ """The active trace id, or a fresh uuid when there is no SDK/span to read one from."""
75
+ return _trace_id() or uuid.uuid4().hex
76
+
77
+
78
+ def current() -> dict:
79
+ return dict(_FIELDS.get())
80
+
81
+
82
+ def bind(**fields) -> None:
83
+ """Add ids to THIS THREAD's correlation context, for the rest of the thread's life.
84
+
85
+ Deliberately not a context manager — see this module's own docstring for why the binding
86
+ outliving the handler is the point, not an oversight."""
87
+ _FIELDS.set({**_FIELDS.get(), **{k: v for k, v in fields.items() if v is not None}})
88
+
89
+
90
+ class CorrelationFilter(logging.Filter):
91
+ """Stamps every record passing a handler with the current context's ids.
92
+
93
+ Never overwrites: a call site that passed its own `extra={"task_id": ...}` keeps it, so this
94
+ is additive to existing behaviour rather than a replacement for it. The stamped attributes
95
+ are ordinary record attributes, which is precisely what OTel's own `LoggingHandler` turns
96
+ into OTLP log attributes — and Loki, in turn, into structured metadata a `| task_id = "…"`
97
+ matcher filters on with no parser in front of it."""
98
+
99
+ def filter(self, record: logging.LogRecord) -> bool:
100
+ for key, value in _FIELDS.get().items():
101
+ if not hasattr(record, key):
102
+ setattr(record, key, value)
103
+ if not hasattr(record, "correlation_id"):
104
+ # NOTHING BOUND — which is precisely the shape of a call the framework turned away
105
+ # before any handler of ours could run: an undeclared door, a payload the card's own
106
+ # `request_schema` rejects, a body that is not JSON. Since ADR-PASH-0005 the HTTP
107
+ # binding holds its SERVER span open across all of those, parented on the caller's
108
+ # own `traceparent`, so the active trace id here is the very value `bind()` would
109
+ # have used. Falling back to it files those records under the run that caused them
110
+ # instead of under nothing at all. `task_id` genuinely cannot be recovered this way
111
+ # and is left absent: a payload the schema rejected may not carry one.
112
+ fallback = _trace_id()
113
+ if fallback:
114
+ record.correlation_id = fallback
115
+ return True
116
+
117
+
118
+ # The ids Loki gets as structured metadata are invisible in `kubectl logs`, which is still the
119
+ # first place anyone looks when the telemetry backend itself is what's under suspicion.
120
+ CONSOLE_IDS = ("correlation_id", "task_id", "attempt")
121
+
122
+
123
+ class ConsoleFormatter(logging.Formatter):
124
+ """Appends whichever correlation ids a record actually carries.
125
+
126
+ A plain `%(task_id)s` in the format string would instead raise `Formatting field not found`
127
+ for every record emitted outside a request — the startup line, anything on a thread that never
128
+ bound — which is exactly how a logging change takes a process down. Nothing is defaulted onto
129
+ the record either: an id that isn't there stays absent, so the OTLP side never carries a
130
+ placeholder value the dashboard would then have to filter back out."""
131
+
132
+ def format(self, record: logging.LogRecord) -> str:
133
+ line = super().format(record)
134
+ ids = " ".join(
135
+ f"{key}={getattr(record, key)}" for key in CONSOLE_IDS if hasattr(record, key)
136
+ )
137
+ return f"{line} [{ids}]" if ids else line
138
+
139
+
140
+ def install() -> None:
141
+ """Attach the filter to every handler on the root logger.
142
+
143
+ Call AFTER `papeete_observability.configure()` and after any console handler is added — this
144
+ walks the handlers that exist at the moment it runs."""
145
+ correlation_filter = CorrelationFilter()
146
+ for handler in logging.getLogger().handlers:
147
+ handler.addFilter(correlation_filter)
148
+
149
+
150
+ # ── the step vocabulary ───────────────────────────────────────────────────────────────────────
151
+ #
152
+ # One JSON object per step, so the dashboard reads them with the same `| json` it already uses for
153
+ # the session transcript, and tells the two apart by `event`. `phase` is start / ok / failed —
154
+ # a start line with no matching ok is a step that was still running when the pod went away, which
155
+ # a duration-only record could never show.
156
+
157
+ def _emit(level: int, record: dict) -> None:
158
+ # `level` goes INTO the JSON body as well as onto the record. Severity does reach Loki through
159
+ # OTLP, but exactly which field it lands in is the ingester's business, not something a
160
+ # dashboard query should be pinned to; a `level` the emitter wrote itself is one `| json` away
161
+ # in every backend, which is what the product dashboard's own failure panel filters on.
162
+ body = {"level": logging.getLevelName(level).lower(), **record}
163
+ logging.getLogger(STEP_LOGGER).log(level, json.dumps(body, default=str, ensure_ascii=False))
164
+
165
+
166
+ def event(name: str, *, level: int = logging.INFO, **fields) -> None:
167
+ """A point in the pipeline with no duration — a verdict, a published ref, a PR url.
168
+
169
+ `level` is keyword-only — a best-effort step that failed without stopping the run
170
+ (`logging.WARNING`) still reads as one `event` to the dashboard's `| json`, and lands in the
171
+ body as `level` (see `_emit`), so `**fields` must not carry a key of that name."""
172
+ _emit(level, {"event": "event", "step": name, **fields})
173
+
174
+
175
+ @contextmanager
176
+ def stage(name: str, **fields):
177
+ """A step with a beginning and an end, logged as both — and as `failed` with the exception's
178
+ own text if it raises, before the exception continues on its way untouched."""
179
+ started = time.monotonic()
180
+ _emit(logging.INFO, {"event": "step", "step": name, "phase": "start", **fields})
181
+ try:
182
+ yield
183
+ except BaseException as e: # noqa: BLE001 — re-raised below
184
+ _emit(logging.ERROR, {
185
+ "event": "step", "step": name, "phase": "failed",
186
+ "duration_ms": round((time.monotonic() - started) * 1000),
187
+ "error": f"{type(e).__name__}: {e}", **fields,
188
+ })
189
+ raise
190
+ _emit(logging.INFO, {
191
+ "event": "step", "step": name, "phase": "ok",
192
+ "duration_ms": round((time.monotonic() - started) * 1000), **fields,
193
+ })
@@ -0,0 +1,434 @@
1
+ """`ClaudeCodeEngine` — judgement by running a headless Claude Code session against a fresh clone.
2
+
3
+ WHY A CUSTOM ENGINE, NOT `papeete_actor_synchronous_messaging.engine.resolve("claude")`. The
4
+ built-in `ClaudeEngine` shells out to the raw `anthropic` Python SDK — `anthropic.Anthropic()`,
5
+ resolving `ANTHROPIC_API_KEY`, then `ANTHROPIC_AUTH_TOKEN`, then an `ant auth login` profile.
6
+ Every one of those is a metered API credential. Subscription OAuth tokens (`claude setup-token`
7
+ → `CLAUDE_CODE_OAUTH_TOKEN`) are scoped to the `claude` CLI / claude.ai specifically and are NOT
8
+ among the credentials the raw SDK will resolve. So the only way to spend a subscription's own
9
+ credit rather than a separate metered key is to shell out to the `claude` CLI itself, which is
10
+ what this file does. It still satisfies the `Engine` port (`name` + `judge(system, prompt,
11
+ schema=None) -> dict`) — the port makes no promise about how long or heavy a judgement is.
12
+
13
+ **Do not set `ANTHROPIC_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`) in a container running this engine.**
14
+ In `claude -p` non-interactive mode an API key present in the environment is ALWAYS preferred over
15
+ `CLAUDE_CODE_OAUTH_TOKEN`, silently routing every session through metered billing instead. There is
16
+ no warning and no visible difference in the transcript; the only symptom is the bill.
17
+
18
+ PAYLOAD-DRIVEN, NOT CARD-DRIVEN. The caller supplies everything a session needs to work — this
19
+ engine never clones a repo merely to look up what a task IS. A missing required field is refused
20
+ by the framework's own schema gate before any engine time is spent, so there is no eligibility
21
+ check here either.
22
+
23
+ CARRIES NO CAPABILITY LITERAL. Every identifier it needs comes from `CapabilityConfig`, which
24
+ derives all of them from one capability id and one repo (see `config.py`). The system prompt is
25
+ never invented here either: `Actor.judge()` builds it from the card's own `means:`/`completion:`
26
+ prose and hands it in as `system`, which this engine passes straight through via
27
+ `--append-system-prompt`, unmodified.
28
+
29
+ WHAT THIS ENGINE DOES NOT DO. It never commits, pushes, or opens a pull request — that is
30
+ `handler.py`'s job, and `handler.py`'s own containment check is the actual enforcement of the
31
+ write boundary this engine's prompt only *asks* the session to respect. On success it
32
+ deliberately does NOT clean up its own clone: the handler still needs it to commit and push, and
33
+ is the one that removes it, on every path including containment failure.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import logging
39
+ import os
40
+ import shutil
41
+ import subprocess
42
+ import tempfile
43
+ import threading
44
+ from pathlib import Path
45
+
46
+ from papeete_actor_synchronous_messaging.engine import EngineError
47
+
48
+ from . import correlation, grounding
49
+ from .config import CapabilityConfig
50
+
51
+ DEFAULT_CLONE_TIMEOUT_S = 120
52
+ DEFAULT_SESSION_TIMEOUT_S = 1800
53
+ DEFAULT_MAX_TURNS = 60
54
+
55
+
56
+ # ── stream-json projection: keeping every emitted log line inside Loki's max_line_size ────────
57
+ #
58
+ # WHY PROJECT RATHER THAN LOG THE RAW EVENT. `--output-format stream-json` emits one JSON object
59
+ # per turn — the inner conversation this actor's audit trail is made of. Two of its fields are
60
+ # unbounded: `tool_use.input` (for `Write`, the whole file body; for `Edit`, both sides of the
61
+ # replacement) and `tool_result.content` (for `Read`/`Grep`/`Bash`, the whole output). Nothing
62
+ # else is: a `text` block is a few hundred bytes. So clipping those two, and only those, keeps
63
+ # the entire conversation readable while bounding every line — and what gets clipped is
64
+ # recoverable from the branch `handler.py` pushes anyway.
65
+ #
66
+ # WHY IT MATTERS. Loki's `max_line_size` is 256KB with `max_line_size_truncate: false` — an
67
+ # oversized line is REJECTED OUTRIGHT, not trimmed, so a single large `Read` would silently
68
+ # delete exactly the turn worth reading while leaving the rest of the session intact. The budget
69
+ # below is a quarter of that, which also sidesteps Loki parsing `KB` as 1000 rather than 1024.
70
+ #
71
+ # AND WHY NOT RECORD ATTRIBUTES. The OTel `LoggingHandler` maps the formatted message to the OTLP
72
+ # body (the line Loki measures) and record attributes to log-record attributes, which land in Loki
73
+ # structured metadata under their own separate caps (`max_structured_metadata_size: 64KB`,
74
+ # `max_structured_metadata_entries_count: 128`). Payload cannot be smuggled out of the line limit
75
+ # by moving it there — that channel is for correlation ids, which `correlation.py` stamps onto
76
+ # every record this process emits without any call site here passing them.
77
+
78
+ LINE_BUDGET = 64 * 1024 # bytes per emitted log line
79
+ BLOB_HEAD = 2000 # bytes kept from the front of an unbounded value
80
+ BLOB_TAIL = 2000 # ...and from the back: a Bash failure lives in the tail, not the head
81
+ PROSE = 8000 # text/thinking/result — prose IS the audit trail, give it more room
82
+
83
+
84
+ def _clip(value, head: int = BLOB_HEAD, tail: int = BLOB_TAIL) -> str:
85
+ """Head+tail slice of a value, budgeted in BYTES (not characters — the limit Loki enforces
86
+ is on the UTF-8 encoded line), with the true size recorded in the marker."""
87
+ if not isinstance(value, str):
88
+ value = json.dumps(value, default=str)
89
+ raw = value.encode("utf-8", "replace")
90
+ if len(raw) <= head + tail:
91
+ return value
92
+ return (raw[:head].decode("utf-8", "replace")
93
+ + f"\n…[clipped {len(raw) - head - tail} of {len(raw)} bytes]…\n"
94
+ + raw[-tail:].decode("utf-8", "replace"))
95
+
96
+
97
+ def _project(event: dict) -> dict | None:
98
+ """One stream-json event -> a compact dict, or None to drop it entirely."""
99
+ kind = event.get("type")
100
+
101
+ if kind == "system" and event.get("subtype") == "init":
102
+ # The raw init event is ~2.2KB of tools/skills/slash_commands inventory. Four fields of
103
+ # it are worth keeping — `session_id` is the join key to the CLI's own full transcript,
104
+ # which it writes to $HOME/.claude/projects/<slug>/<session_id>.jsonl regardless of
105
+ # --output-format.
106
+ return {"event": "init", "session_id": event.get("session_id"),
107
+ "model": event.get("model"), "cwd": event.get("cwd")}
108
+
109
+ if kind == "result":
110
+ usage = event.get("usage") or {}
111
+ return {"event": "result", "session_id": event.get("session_id"),
112
+ "subtype": event.get("subtype"), "is_error": event.get("is_error"),
113
+ "num_turns": event.get("num_turns"), "duration_ms": event.get("duration_ms"),
114
+ "cost_usd": event.get("total_cost_usd"),
115
+ "input_tokens": usage.get("input_tokens"),
116
+ "output_tokens": usage.get("output_tokens"),
117
+ "result": _clip(event.get("result", ""), PROSE, PROSE)}
118
+
119
+ if kind not in ("assistant", "user"):
120
+ return None # rate_limit_event and friends carry no audit value
121
+
122
+ blocks = []
123
+ for block in (event.get("message") or {}).get("content", []):
124
+ if not isinstance(block, dict):
125
+ continue
126
+ btype = block.get("type")
127
+ if btype in ("text", "thinking"):
128
+ blocks.append({btype: _clip(block.get(btype, ""), PROSE, PROSE)})
129
+ elif btype == "tool_use":
130
+ blocks.append({"tool_use": block.get("name"), "id": block.get("id"),
131
+ "input": {k: _clip(v) for k, v in (block.get("input") or {}).items()}})
132
+ elif btype == "tool_result":
133
+ # `content` is a str for a text result and a list of blocks otherwise — `_clip`
134
+ # json-dumps the latter rather than this guessing at its shape.
135
+ blocks.append({"tool_result": block.get("tool_use_id"),
136
+ "is_error": block.get("is_error", False),
137
+ "content": _clip(block.get("content", ""))})
138
+ return {"event": kind, "blocks": blocks} if blocks else None
139
+
140
+
141
+ def _line(record: dict) -> str:
142
+ """Serialize, then hard-enforce the budget.
143
+
144
+ The per-field clipping in `_project` is what keeps lines small; this is what makes "no line
145
+ exceeds the budget" a property of the code rather than a hope — a `tool_use` carrying a
146
+ hundred just-under-threshold keys would otherwise slip through the sum."""
147
+ line = json.dumps(record, default=str, ensure_ascii=False)
148
+ if len(line.encode("utf-8")) > LINE_BUDGET:
149
+ half = LINE_BUDGET // 2 - 200
150
+ line = json.dumps({"event": record.get("event"), "over_budget": True,
151
+ "clipped": _clip(line, half, half)}, ensure_ascii=False)
152
+ return line
153
+
154
+
155
+ def _redact(text: str, secret: str | None) -> str:
156
+ return text.replace(secret, "***") if secret else text
157
+
158
+
159
+ def _payload_from_prompt(prompt: str) -> dict:
160
+ """Recover the full payload dict from `Actor.judge()`'s own fixed prompt format
161
+ (`papeete_actor_synchronous_messaging.actor.Actor.judge`):
162
+
163
+ f"verb: {verb}\\ndoor: {offer.id}\\n{as_prompt_json(situation)}"
164
+
165
+ where `situation["payload"]` is exactly what the caller sent at this door.
166
+ """
167
+ lines = prompt.split("\n", 2)
168
+ if len(lines) < 3:
169
+ raise EngineError(f"prompt does not follow Actor.judge()'s fixed format: {prompt!r}")
170
+ try:
171
+ situation = json.loads(lines[2])
172
+ return situation["payload"]
173
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
174
+ raise EngineError(f"could not recover payload from prompt: {e}") from e
175
+
176
+
177
+ class ClaudeCodeEngine:
178
+ """Judgement by shelling out to the `claude` CLI, against a fresh private clone."""
179
+
180
+ def __init__(self, config: CapabilityConfig, *,
181
+ github_token: str | None = None, claude_bin: str = "claude",
182
+ clone_timeout: int = DEFAULT_CLONE_TIMEOUT_S,
183
+ fetch_timeout: int = grounding.DEFAULT_FETCH_TIMEOUT_S,
184
+ session_timeout: int = DEFAULT_SESSION_TIMEOUT_S,
185
+ max_turns: int = DEFAULT_MAX_TURNS):
186
+ self.config = config
187
+ # The engine's `name` is what the card's door names, so it comes from the sidecar rather
188
+ # than being fixed here — the port requires the attribute, not a particular value.
189
+ self.name = config.engine
190
+
191
+ self.github_token = github_token or os.environ.get("GITHUB_TOKEN")
192
+ if not self.github_token:
193
+ raise RuntimeError(
194
+ "ClaudeCodeEngine needs GITHUB_TOKEN in the environment — a fine-grained PAT with "
195
+ f"contents:write on {config.source_repo}, plus read-only contents on "
196
+ f"{config.registry_repo} and whatever else this capability's `ground_in` fetches "
197
+ "resolve through."
198
+ )
199
+ self.claude_bin = claude_bin
200
+ self.clone_timeout = clone_timeout
201
+ self.fetch_timeout = fetch_timeout
202
+ self.session_timeout = session_timeout
203
+ self.max_turns = max_turns
204
+ self._configure_git_credentials()
205
+
206
+ # ── the one Engine method ──────────────────────────────────────────────────────────────
207
+
208
+ def judge(self, *, system: str, prompt: str, schema: dict | None = None) -> dict:
209
+ payload = _payload_from_prompt(prompt)
210
+ task_id = payload["task_id"]
211
+ # The FIRST thing this door does, before anything that could fail: from here to the end
212
+ # of this request's own thread, every record — this module's, handler.py's, and the HTTP
213
+ # binding's own access line — carries both ids. `correlation_id()` reads the trace id the
214
+ # orchestrating actor already propagated, so every actor in the pipeline agrees on it
215
+ # without any of them passing it (see correlation.py).
216
+ correlation.bind(correlation_id=correlation.correlation_id(), task_id=task_id)
217
+
218
+ clone_dir = Path(tempfile.mkdtemp(prefix=self.config.clone_prefix(task_id)))
219
+
220
+ try:
221
+ with correlation.stage("clone-code", repo=self.config.source_repo):
222
+ self._clone(clone_dir)
223
+
224
+ branch = f"impl/{task_id}"
225
+ self._git(clone_dir, ["checkout", "-b", branch])
226
+
227
+ # Grounding is a PRECONDITION, not a request — see grounding.py. It runs before the
228
+ # prompt is even built, and a failure here stops the request before any session time
229
+ # is spent, which is the cheapest place for it to stop.
230
+ for entry in self.config.ground_in:
231
+ with correlation.stage(f"ground-{entry.name}", into=entry.into, load=entry.load):
232
+ envelope = grounding.fetch(self.config, entry, timeout=self.fetch_timeout)
233
+ grounding.write_envelope(self.config, entry, clone_dir, envelope)
234
+ grounding.render_claude_md(self.config, clone_dir)
235
+
236
+ situational_prompt = self._situational_prompt(payload)
237
+ with correlation.stage("claude-session", branch=branch,
238
+ max_turns=self.max_turns, timeout_s=self.session_timeout):
239
+ summary = self._invoke_claude(clone_dir, system, situational_prompt)
240
+ except BaseException:
241
+ # Every failure path removes the clone. Success does not: the clone is handed off
242
+ # live, and `handler.py` is what removes it once it has committed and pushed — or
243
+ # refused for containment.
244
+ _rmtree(clone_dir)
245
+ raise
246
+
247
+ return {
248
+ "implemented": True,
249
+ "clone_dir": str(clone_dir),
250
+ "branch": branch,
251
+ "summary": summary,
252
+ }
253
+
254
+ # ── git ─────────────────────────────────────────────────────────────────────────────────
255
+
256
+ def _configure_git_credentials(self) -> None:
257
+ """Make GITHUB_TOKEN available to subprocesses that do their own git clones.
258
+
259
+ A `ground_in` tool typically delegates git auth entirely to git's own credential
260
+ resolution and never takes a token itself. A global URL rewrite is the one hook available
261
+ to make those clones use this token too, without patching the tool. Idempotent; safe to
262
+ call on every construction.
263
+ """
264
+ subprocess.run(
265
+ ["git", "config", "--global",
266
+ f"url.https://x-access-token:{self.github_token}@github.com/.insteadOf",
267
+ "https://github.com/"],
268
+ check=True, capture_output=True, text=True,
269
+ )
270
+
271
+ def _clone(self, dest: Path) -> None:
272
+ # Full clone, not --depth 1: `papeete_version.compute()`'s own `semver_base()` does `git
273
+ # describe --tags --match <name>/v*` against this clone, which needs the matching tag's
274
+ # commit reachable in local history — a shallow clone only has the tip commit, and breaks
275
+ # the moment the tag isn't that exact commit (verified live: it worked only by accident
276
+ # while the repo's origin/main was still a single commit).
277
+ url = f"https://x-access-token:{self.github_token}@github.com/{self.config.source_repo}.git"
278
+ try:
279
+ subprocess.run(
280
+ ["git", "clone", url, str(dest)],
281
+ check=True, capture_output=True, text=True, timeout=self.clone_timeout,
282
+ )
283
+ except subprocess.CalledProcessError as e:
284
+ raise EngineError(
285
+ f"could not clone {self.config.source_repo}: "
286
+ f"{_redact(e.stderr, self.github_token)}"
287
+ ) from e
288
+ except subprocess.TimeoutExpired as e:
289
+ raise EngineError(
290
+ f"cloning {self.config.source_repo} timed out after {self.clone_timeout}s"
291
+ ) from e
292
+
293
+ def _git(self, clone_dir: Path, args: list[str]) -> str:
294
+ try:
295
+ result = subprocess.run(
296
+ ["git", *args], cwd=clone_dir, check=True, capture_output=True, text=True,
297
+ )
298
+ except subprocess.CalledProcessError as e:
299
+ raise EngineError(
300
+ f"git {' '.join(args)} failed: {_redact(e.stderr, self.github_token)}"
301
+ ) from e
302
+ return result.stdout
303
+
304
+ # ── situational prompt, built from the caller's own payload ───────────────────────────
305
+
306
+ def _situational_prompt(self, payload: dict) -> str:
307
+ """The task, and nothing else.
308
+
309
+ NOTE WHAT IS ABSENT: any instruction to go and read the capability's context. That used
310
+ to be a paragraph here, naming two files in a sibling tempdir and asking the session to
311
+ read them "before you start". It is a precondition now — the generated `CLAUDE.md` is
312
+ loaded before turn one — so asking for it again would be asking for something already
313
+ done, in the one place a session is most likely to take the instruction literally and
314
+ spend a turn on it.
315
+ """
316
+ config = self.config
317
+ task_id = payload["task_id"]
318
+ boundary = ", ".join(config.writes_only_under)
319
+
320
+ sections = [
321
+ f"# Implement {task_id} for {config.capability}: {payload['title']}\n\n"
322
+ f"You are working inside your own private clone (branch already checked out). "
323
+ f"Write ONLY under {boundary} — nothing else in this clone is yours to change. "
324
+ f"Do not `git add`, `git commit`, or `git push` — that is handled outside this "
325
+ f"session. Iterate the test suite(s) of whichever component(s) you touch "
326
+ f"({', '.join(config.test_paths)}) until green before you finish.\n\n"
327
+ f"Scope this session to {task_id}'s own Definition of Done against the CURRENT state "
328
+ f"of {boundary} — extend what's already there for whichever component(s) this task "
329
+ f"touches, don't re-derive or re-verify the whole capability's surface from scratch."
330
+ ]
331
+ if payload.get("context"):
332
+ sections.append(f"## Context\n{payload['context']}")
333
+ sections.append(
334
+ "## Definition of done\n"
335
+ + "\n".join(f"- {item}" for item in payload["definition_of_done"])
336
+ )
337
+ if payload.get("remediation_context"):
338
+ sections.append(
339
+ "## Remediation — the prior attempt's failing test criteria\n"
340
+ f"{payload['remediation_context']}"
341
+ )
342
+ return "\n\n".join(sections)
343
+
344
+ # ── the judgement itself: a claude -p session against the checked-out clone ────────────
345
+
346
+ def _invoke_claude(self, clone_dir: Path, system: str, situational_prompt: str) -> str:
347
+ """Run the session, streaming every turn to the log as it happens.
348
+
349
+ `--output-format stream-json --verbose` rather than `--output-format json`: the latter
350
+ emits ONE object at the end, holding only the final assistant text, so the whole inner
351
+ conversation — what was read, what was run, what was decided — existed nowhere durable
352
+ once the pod went away. The projection above is what makes streaming it affordable.
353
+
354
+ `Popen` rather than `subprocess.run`: reading line by line is what lets each turn be
355
+ logged as it happens rather than after the session ends, and it stops a 30-minute
356
+ session's entire output being buffered in a pod capped at 2Gi.
357
+ """
358
+ cmd = [
359
+ self.claude_bin, "--print", "--output-format", "stream-json", "--verbose",
360
+ "--append-system-prompt", system,
361
+ "--permission-mode", "acceptEdits",
362
+ "--allowedTools", "Bash,Read,Edit,Write,Glob,Grep",
363
+ "--max-turns", str(self.max_turns),
364
+ situational_prompt,
365
+ ]
366
+ # stderr to a temp file, not a second pipe: nothing drains a second pipe while the
367
+ # stdout loop below runs, so a chatty stderr would fill its buffer and deadlock the
368
+ # session. Merging it into stdout is not an option either — it would corrupt the stream.
369
+ with tempfile.TemporaryFile("w+") as errfile:
370
+ try:
371
+ proc = subprocess.Popen(cmd, cwd=clone_dir, stdout=subprocess.PIPE,
372
+ stderr=errfile, text=True, bufsize=1)
373
+ except FileNotFoundError as e:
374
+ raise EngineError(
375
+ f"'{self.claude_bin}' is not on PATH — install @anthropic-ai/claude-code"
376
+ ) from e
377
+
378
+ # A watchdog, not a deadline checked per line: a session that hangs having emitted
379
+ # nothing would never reach another loop iteration to be checked, and `Popen` has no
380
+ # equivalent of `subprocess.run(timeout=...)` while iterating its output.
381
+ timed_out = threading.Event()
382
+
383
+ def _expire() -> None:
384
+ timed_out.set()
385
+ proc.kill()
386
+
387
+ watchdog = threading.Timer(self.session_timeout, _expire)
388
+ watchdog.start()
389
+
390
+ final: dict | None = None
391
+ try:
392
+ for raw in proc.stdout:
393
+ try:
394
+ event = json.loads(raw)
395
+ except json.JSONDecodeError:
396
+ continue # a non-JSON line is noise, never the session's result
397
+ record = _project(event)
398
+ if record is not None:
399
+ # No %-args: `logging` only applies %-formatting when args are passed,
400
+ # so a stray % in a file body cannot raise here. No `extra=` either —
401
+ # `correlation.bind()` in `judge()` above already stamps task_id and
402
+ # correlation_id onto every record emitted on this thread.
403
+ logging.info(_line(record))
404
+ if event.get("type") == "result":
405
+ final = event
406
+ proc.wait()
407
+ finally:
408
+ watchdog.cancel()
409
+ if proc.poll() is None:
410
+ proc.kill()
411
+ proc.wait()
412
+ proc.stdout.close()
413
+
414
+ if timed_out.is_set():
415
+ raise EngineError(
416
+ f"claude session for this task exceeded {self.session_timeout}s"
417
+ )
418
+ errfile.seek(0)
419
+ stderr = errfile.read()
420
+
421
+ if final is None:
422
+ raise EngineError(
423
+ f"claude (rc={proc.returncode}) produced no result event: {stderr[-2000:]}"
424
+ )
425
+ if final.get("is_error") or proc.returncode != 0:
426
+ raise EngineError(
427
+ f"claude session failed (subtype={final.get('subtype')}): "
428
+ f"{final.get('result', '')[:4000]}"
429
+ )
430
+ return final.get("result", "")
431
+
432
+
433
+ def _rmtree(path: Path) -> None:
434
+ shutil.rmtree(path, ignore_errors=True)