step.3.txt -- b3ubot U1.2: first real adapter (P1.M1) -- the `claude-code` adapter (d1 RESOLVED), driving the LOCAL Claude Code client headlessly via `claude --bare -p`. Requires $ANTHROPIC_API_KEY explicitly (a correction to d12's "rides the operator's interactive subscription" framing -- see ground truth below); auto-skips honestly when unavailable, never falls back to an ambient session. Tool execution stays OFF (tool_schema is still INERT, C-2-D from U1.1) -- the narrowest safe invocation. Step: 3 -- promote UoW **U1.2** (end_to_end.md §3, P1.M1) to execution depth: a REAL, network-touching adapter that satisfies U1.1's contract -- the trinity's sole internet-communicating component (design.md §2.9), and the first place B-4 (credential hygiene) and B-5 (egress -- foreshadowed, not yet enforced) actually matter, not just get named. Parent: end_to_end.md §3 U1.2 ("First real adapter (d1 RESOLVED: claude-code)"; objective = "the claude-code adapter driving the LOCAL Claude Code client... B-4 key/ credential hygiene from the first line"; done-when = "the same contract suite passes against the live adapter (auto-skip when no Claude Code client is available)") + design.md §2.4 (adapter interface) + §2.9 (the local trinity -- this adapter's driven process is the ONLY component that touches the internet) + §2.12 (layout: adapters/claude/). UoW: U1.2 -- "First real adapter (claude-code)". Depends on U1.1 (done, step.2, 4cbcad0). Sole blocker of U1.3 (the one-round-trip CLI) and U1.4 (P1 gates + retro). Date: 2026-07-18 (SKELETON -- not executed. Execute on explicit request.) Origin: User directive 2026-07-18: "execute step for U1.2" -- this skeleton is drafted and executed in the same pass, at the operator's request, following the rhythm already established for step.1/step.2. ## (0) Status SKELETON. Nothing built. ⚠ This step is the FIRST one in either repo's history that spawns a REAL external process talking to a real AI provider -- real cost, real account usage. Ground truth below (researched via the Claude Code docs before writing any code) informs a design that only ever executes that live path when the operator has explicitly configured `$ANTHROPIC_API_KEY`; this dev environment does NOT have that key set, so the live leg auto-skips HONESTLY in this session -- itself a real proof of the done-when's "auto-skip when no Claude Code client is available" clause, achieved without spending anything. No live invocation is attempted in this session regardless of whether the skip fires (see Q-3-B). ## (1) Ground truth probed at drafting EXISTS (headless invocation, verified against the official Claude Code docs, not assumed): `claude --bare -p "" --output-format json` runs one-shot, non-interactively, and exits. `--bare` skips auto-discovery of hooks/skills/ MCP servers/CLAUDE.md (reproducibility -- this adapter must not silently pick up whatever hooks happen to exist in the invoking machine's config). `--output-format json` returns a structured blob: `result` (text), `session_id`, `total_cost_usd`, usage fields. FOUND (a real correction to design.md d12's framing): in `--bare` mode, authentication comes from `$ANTHROPIC_API_KEY` ONLY -- NOT the interactive OAuth/keychain session an operator's normal `claude` login uses. d12's own text ("the trinity's Claude Code client rides the OPERATOR'S subscription -- fine for development") assumed the SUBSCRIPTION-riding shape; the SAFE, reproducible, automatable invocation mode (`--bare`) does not support that at all. The alternative -- non-bare `-p`, which WOULD ride the ambient interactive session -- was deliberately NOT tested live this step (Q-3-B): it also auto-discovers hooks/CLAUDE.md/MCP servers from wherever it's invoked, which is a real, unbounded surface for an automated caller to inherit unpredictably, and -- more pointedly -- running it from WITHIN a live interactive Claude Code session (the one executing this very step) risks spending the operator's real account usage on an experiment, which is exactly the class of action this project's own standing discipline says to pause and confirm before taking, not silently try. This step's adapter therefore REQUIRES `$ANTHROPIC_API_KEY` explicitly (B-4's own "environment- only" wording, extended to be the SOLE auth path) and never falls back to an ambient session. d12 needs a design.md correction note at closure (2.7). FOUND (tool restriction): `--allowedTools ""` (empty allowlist) plus `--bare` is the narrowest documented invocation -- no file/shell/network tool access for the spawned process. This step's adapter hardcodes this; there is no "unrestricted" mode exposed. Direct consequence: with all tools disabled, the live Claude Code process CANNOT emit a genuine TOOL_CALLS response, matching (not contradicting) U1.1's own C-2-D lock that `tool_schema` is INERT until P3 wires real tool execution. FOUND (no zero-cost live pre-check exists): the docs name no "ping" endpoint; the only honest, zero-cost availability check is LOCAL: is the `claude` binary on PATH (`shutil. which`, confirmed present in this dev environment) AND is `$ANTHROPIC_API_KEY` set and non-empty (confirmed ABSENT in this dev environment, checked directly, value never printed). Both together gate `is_available()`; the adapter NEVER shells out just to check. FOUND (abort() has a real architectural limit here, not just a provisional stub): U1.1's MockAdapter is a generator that yields per fixture item, giving a natural checkpoint for a synchronous test to call abort() mid-stream. This adapter's single live call blocks inside one subprocess wait; a caller on the SAME thread has no opportunity to call abort() while `run()` is blocked. Using `Popen` + `.communicate(timeout=...)` (rather than the simpler `subprocess.run`) at least gives a REAL process handle `abort()` can `.terminate()` from a DIFFERENT thread/ process -- P3's orchestrator (async/threaded scheduling) is where that path gets exercised for real; the single- threaded contract suite cannot exercise it today, and should say so rather than fake a pass (Q-3-C). FOUND (SIGTERM exit behavior, per docs): a terminated Claude Code process aborts its turn, tears down its own process tree, and exits 143 -- this adapter treats exit 143 as the ABORTED signature specifically (distinct from any other non-zero exit, which is a genuine run error). EXISTS (an alternative: the claude-agent-sdk): a Python SDK exists (in-process, no subprocess). NOT used this step (Q-3-D) -- it is a new dependency this codebase's minimalism discipline (Q-1-C/Q-2-B) has no current justification for; the CLI subprocess path needs zero new pip package. MISSING: adapters/claude/ (design.md §2.12's named layout does not exist yet); the ClaudeCodeAdapter class; the availability check; the JSON-response parser (no REAL captured response exists to test against -- this session has no live key -- so the parser fixture is built from the DOCUMENTED shape and flagged for re-validation against a real capture once a key exists, Q-3-E); the contract suite's skipif wiring; the two adapter-capability flags (SUPPORTS_TOOL_CALLS, SUPPORTS_INTERLEAVED_ABORT) that U1.1's ABC needs added so the shared suite can honestly skip what THIS adapter's safe configuration structurally cannot do, without hardcoding a provider-name string inside tests/test_pal_contract.py's test bodies (which would itself be a small, ironic violation of "no provider name outside adapters/" -- capability flags keep the skip reason principled, not name-matched). ## (2) What U1.2 delivers 2.1 **Capability flags on the ABC** (app/pal/adapter.py, a small extension of U1.1's file): `SUPPORTS_TOOL_CALLS: bool = True` and `SUPPORTS_INTERLEAVED_ABORT: bool = True` as class defaults -- MockAdapter needs no change (both stay True, matching its actual behavior); ClaudeCodeAdapter overrides both to False, each with an inline reason. 2.2 **app/pal/errors.py** (new, shared -- any real adapter can raise these): `AdapterError`, `AdapterUnavailableError` (raised by `run()` if called while unavailable -- defensive, "fail loud, name the fix"), `AdapterRunError` (non-zero exit / bad JSON / any run-time failure, message capped and never includes the API key -- B-4). 2.3 **adapters/claude/adapter.py**: `ClaudeCodeAdapter(Adapter)`. `is_available() -> (bool, str)`: binary-on-PATH AND `$ANTHROPIC_API_KEY` set, zero subprocess spawned to check. `capabilities()`: `tools=False` (hardcoded off this step), `streaming=False` (this adapter uses single-blob JSON, not `stream-json`, honestly stated), `context_window` a named, commented best-known constant (not queried live -- an honest interim), `cost_model` states pricing is Anthropic's published rate, not locally computed (mirrors b3u's C-21-B "never compute money locally" convention). `run(request)`: flattens `request.context` into one role-tagged prompt string (headless `-p` takes one string; genuine multi-turn state does not exist in this adapter yet, same statelessness as MockAdapter), `Popen`s `claude --bare -p --output-format json --allowedTools ""` with a MINIMAL subprocess environment (`PATH`/`HOME`/`ANTHROPIC_API_KEY` only -- nothing else from the parent environment leaks in, B-4/B-5 in spirit), a bounded timeout, parses the JSON blob into Response object(s) tagged TEXT then DONE (never TOOL_CALLS, per 2.1's flag), raises `AdapterUnavailableError`/`AdapterRunError` on the documented failure paths. `abort(run_id)`: `.terminate()`s the tracked `Popen` handle if RUNNING, same IDLE/RUNNING/DONE/ABORTED status-table shape as MockAdapter for interface consistency. 2.4 **Contract suite wiring** (tests/test_pal_contract.py, extended not rewritten): claude-code registered via ONE `pytest.param(..., marks=pytest.mark.skipif(not available, reason=...))` entry (Q-2-D's mechanism proven live by this real second adapter, not just a throwaway dry-run this time). The two tests that need `SUPPORTS_TOOL_CALLS`/ `SUPPORTS_INTERLEAVED_ABORT` gates get a short, capability- flag-driven `pytest.skip()` guard at their top -- zero other test bodies touched. 2.5 **Adapter-internal unit tests** (tests/test_claude_code_adapter.py, new -- NOT part of the shared contract suite, this adapter's own logic): context-flattening, `is_available()`'s two- condition logic (binary present/absent x key present/absent, all 4 branches, zero live invocation in any of them), and JSON- response parsing against a fixture literal BUILT FROM THE DOCUMENTED SHAPE (explicitly commented: not a captured real response, this session had no live key -- Q-3-E names the follow-up). 2.6 **B-4 hygiene, concretely**: grep-based regression guard (G6) that no test, fixture, or source file in this step ever prints, logs, or asserts-the-VALUE-of `$ANTHROPIC_API_KEY` -- only its presence/absence. 2.7 **Closure**: end_to_end.md §11 U1.2 -> done; design.md d12 gains a short correction note (this step found `--bare` mode needs an explicit API key, not the ambient subscription -- recorded where d12 lives, not silently overwritten); memory. Retro pair via step_gdiff. EXPLICITLY OUT OF SCOPE (each belongs to a later UoW, or is permanently out per this step's own safety posture): - Any invocation that RIDES the operator's ambient interactive session (non-`--bare`) -- deliberately not built or tested this step (Q-3-B); if ever wanted, it is a NEW, separately-considered capability, not a fallback path this adapter silently takes. -> never (unless explicitly reopened) - Real tool execution / a non-empty `--allowedTools` mode -- gated on P3's tool surface existing at all. -> P3 - `--output-format stream-json` / genuine streaming responses -- this step's `capabilities()` honestly reports `streaming=False` for what THIS adapter actually does today. -> later - Multi-turn conversational state across `run()` calls -- headless `-p` is one-shot; real state needs the orchestrator (P3). -> P3 - The claude-agent-sdk alternative (Q-3-D) -- CLI subprocess only, this step. -> later, if ever - A REAL captured JSON response fixture (Q-3-E) -- this session has no live key to capture one from. -> next session with a key - The one-round-trip CLI, orchestrator, ledger engine -> U1.3+ ## (3) Verification gates G1 DONE-WHEN (end_to_end.md verbatim): the SAME contract suite (tests/test_pal_contract.py) exercises claude-code alongside mock; the claude-code parametrization SKIPS (not fails, not errors) in an environment lacking `$ANTHROPIC_API_KEY`, verified by actually running the suite in exactly that (this dev box's real) condition. G2 NO PROVIDER NAME OUTSIDE adapters/ (U1.1's G1 precedent, re-run): `grep -rn -e mock -e claude -e openai app/pal/` stays empty after this step's edits to app/pal/adapter.py and the new app/pal/errors.py. G3 CAPABILITY-FLAG SKIPS ARE PRINCIPLED, NOT NAME-MATCHED: the two gated contract tests key off `adapter.SUPPORTS_TOOL_CALLS` / `adapter.SUPPORTS_INTERLEAVED_ABORT`, never an `if name == "claude-code"` string check inside tests/test_pal_contract.py. G4 ZERO LIVE INVOCATION THIS SESSION, PROVEN NOT ASSUMED: no subprocess named `claude` is ever spawned during this step's BUILD or verification (checked -- no process-launch call succeeds past the `is_available()` gate given the confirmed- absent API key); the live-path CODE is reviewed and unit- tested (2.5) but never executed end-to-end this session. G5 B-4 HYGIENE: `$ANTHROPIC_API_KEY`'s VALUE is never printed, logged, or embedded in an assertion anywhere this step touches -- only its presence/absence (G6's grep). G6 MINIMAL SUBPROCESS ENVIRONMENT: adapters/claude/adapter.py's `Popen` call passes an explicitly-built env dict containing only PATH/HOME/ANTHROPIC_API_KEY -- never a bare inherited `os.environ` passthrough (grep-verified: no bare `env=None` / `env=os.environ` call site). G7 NO REGRESSION: the 8 existing U1.1 contract tests (mock leg) stay green, byte-identical in behavior; step.1's `make smoke` still passes unmodified; `make test` picks up the new adapter unit tests with zero new pytest configuration. G8 Hygiene: b3ubot porcelain clean after each commit; retro via step_gdiff. ## (4) LOCKs C-3-A b3ubot porcelain clean after each commit. C-3-B NO AMBIENT-SESSION FALLBACK: this adapter NEVER invokes Claude Code in a mode that could ride an already- authenticated interactive session -- `$ANTHROPIC_API_KEY` is the ONLY accepted credential path, checked explicitly, never silently inferred from CLI login state. C-3-C TOOLS STAY OFF: `--allowedTools ""` is hardcoded, not configurable via any parameter this step exposes -- widening it is P3's job, done deliberately when the tool surface (§2.7) exists to gate it, never as a quiet default change here. C-3-D MINIMAL SUBPROCESS ENVIRONMENT (G6's regression guard): PATH/HOME/ANTHROPIC_API_KEY only, nothing else from the parent process's environment reaches the child. C-3-E CAPABILITY FLAGS OVER NAME CHECKS: any test needing to behave differently per adapter reads a capability flag on the adapter instance, never a literal provider-name string comparison outside adapters/ itself (G3). C-3-F standing locks inherited: B-1 (moot, no CCS surface here), B-3 (no disclosure/push), B-4 (this step's whole design is its first real enforcement point), B-5 (foreshadowed -- the flattened prompt IS everything this adapter sends externally; no egress POLICY gate exists yet, P7's job, but the prompt's full content is exactly what design.md's ProviderRun trail will need to record once P4 exists), B-6 (this step's suite is T0/T1 -- deterministic, gates; no AI review tier exists yet). ## (5) Open questions Q-3-A Prompt flattening shape: role-tagged plain-text lines (`[system]\n...\n[user]\n...`, LEAN -- simplest, matches headless `-p`'s single-string input, human-readable if logged for debugging) vs a more elaborate structured encoding (e.g. XML-ish tags). LEAN: plain role-tagged lines; revisit only if a real live run shows Claude Code parsing it worse than expected (untestable without a key this session). Q-3-B Ambient-session invocation (non-`--bare`, riding the operator's interactive login): explicitly NOT built or tested this step (LEAN -- real, unbounded risk: spends the operator's real account usage AND auto-discovers whatever hooks/CLAUDE.md/MCP config exists at the invocation cwd, neither of which this step can respinsibly own without a much larger design) vs building it as a second, opt-in mode now. LEAN: never build it silently; if the operator wants subscription-riding invocation later, that is its own, separately-considered step with its own safety review, not a flag added quietly here. Q-3-C Interleaved abort() testing: name the limitation honestly via `SUPPORTS_INTERLEAVED_ABORT = False` + a skipped contract test (LEAN -- matches this project's "honest interims, never silently deferred" discipline exactly; a fake pass would be worse than an honest skip) vs building a thread-based test harness to actually exercise `Popen`- level `.terminate()` this step (real engineering, but for a path P3's real orchestrator will need ANYWAY once it exists, so building a THROWAWAY threading harness now mostly duplicates future work). LEAN: honest skip now; P3 is where this gets a real test, using real orchestrator concurrency instead of a bespoke test-only thread. Q-3-D claude-agent-sdk vs CLI subprocess: CLI subprocess (LEAN -- zero new dependency, matches every prior minimalism decision this repo has made) vs the Python Agent SDK (in-process, likely more robust long-term, but a new dependency with its own version/compat surface this step has no pressing need to take on). LEAN: CLI subprocess; revisit if subprocess fragility (parsing, timeouts, zombie processes) becomes a real, observed problem once this adapter is exercised live. Q-3-E JSON-response fixture provenance: built from the DOCUMENTED output shape (LEAN this session -- no live key available to capture a real response; a documented-shape fixture, clearly labeled as such, is still real coverage of the parsing LOGIC, just not proof the live shape matches exactly) vs deferring the parser's unit tests entirely until a real capture exists (leaves the parsing code completely untested this step, worse than a labeled approximation). LEAN: documented-shape fixture now, explicitly flagged in a code comment for re-validation against a REAL captured response the first time this adapter actually runs live (whoever configures the key first should diff the real shape against this fixture and fix any mismatch before trusting the parser). ## (6) Acceptance - [x] app/pal/adapter.py: SUPPORTS_INTERLEAVED_ABORT class default added (2.1, G3). SUPPORTS_TOOL_CALLS DROPPED mid-build -- redundant with the already-contractual capabilities() ["tools"] field; see step.3.diff.txt finding #1. - [x] app/pal/errors.py: AdapterError/AdapterUnavailableError/ AdapterRunError (2.2). - [x] adapters/claude/adapter.py: ClaudeCodeAdapter -- is_available(), capabilities(), run() (Popen, --bare, --allowedTools "", minimal env, timeout, TEXT/DONE-only parsing), abort() (2.3, C-3-B/C/D, G4/G5/G6). - [x] tests/test_pal_contract.py: claude-code registered with a skipif marker; test_tool_calls_response/test_multi_part_stream gated on capabilities()["tools"]/["streaming"] (not a new flag); test_abort_running_run gated on SUPPORTS_INTERLEAVED_ABORT (2.4, G1, G3). - [x] tests/test_mock_adapter.py (new, not in the original plan): test_unrecognized_request_fails_loud MOVED here -- found building the second adapter that it was never a universal contract property, only MockAdapter's own fixture-routing behavior (step.3.diff.txt finding #2). - [x] tests/test_claude_code_adapter.py: context-flattening, all 4 is_available() branches, JSON-parsing against the documented-shape fixture (2.5, Q-3-E noted in-file). - [x] G1 run for real in this dev environment: claude-code parametrization SKIPS with the exact reason "$ANTHROPIC_API_KEY is not set..." (pytest -v -rs output captured); mock leg unaffected, 7/7 still green (plus 10 new adapter-internal + mock-specific unit tests, all green). - [x] G2/G3/G5/G6 grep-verified. - [x] G7: full rm -rf .venv work && make venv/smoke/test replay clean, zero regression (17 passed, 7 honestly skipped). - [x] Closure: end_to_end §11 U1.2 -> done + design.md d12 correction note + memory; retro pair via step_gdiff (2.7, G8). ## (7) Hash backfill SKELETON commit (this file + ledger row U1.2 -> active): effe53f BUILD commit (adapter + errors + tests, live-skip verified): 96afa52 RETRO commit (retro pair + ledger done): (HEAD) End of skeleton.