step.7.txt -- b3ubot U1.5: the human-relay adapter (P1.M1) -- a THIRD first-class PAL implementation, `adapters/human/`, where the developer manually relays request context to an AI coding tool running in a SEPARATE session and pastes its response back. Validates the whole Adaptive Programming workflow (skeleton -> ratify -> execute -> verify -> retro) without any AI-tool-specific automated adapter, and decouples the workflow's validation from any single vendor's API timing (design.md §5 d13). Step: 7 -- promote UoW **U1.5** (end_to_end.md §3, P1.M1; not originally planned -- new architectural insight, operator directive 2026-07-20) to execution depth: a third `Adapter` implementation that needs no AI-vendor integration at all, proving the orchestrator/PAL/oracle machinery is genuinely provider-agnostic one adapter earlier than U6.1's planned second REAL (automated) adapter. Parent: end_to_end.md §3 U1.5 (new bullet, this step) + design.md §2.4 (adapter interface, "The mock provider is a first-class adapter..." bullet, now joined by a third) + §5 d13 (new decision, this step) + §2.12 (layout: adapters/human/). UoW: U1.5 -- "Human-relay adapter (manual, decouples workflow from any AI tool)". Depends on U1.1 (done, step.2, 4cbcad0) for the Adapter ABC/envelope; sibling to U1.2 (claude-code, done, step.3, 96afa52) and U1.4 (P1 gates + retro, still todo -- U1.5 does not block or get blocked by U1.4; it is additive to P1.M1's adapter roster, not on U1.4's critical path). Date: 2026-07-20 (drafted and executed in the same pass, at the operator's explicit request -- the rhythm already established for step.1/step.3.) Origin: User directive 2026-07-20: before any AI-tool-specific automated adapter is built, the whole Adaptive Programming workflow can be validated with the human developer standing in as the relay between b3ubot and an AI coding tool running in a SEPARATE terminal (e.g. Claude Code in Shell 1, b3ubot in Shell 2). The developer manually copies b3ubot's request context into Shell 1 and pastes the AI's response back into Shell 2. ## (0) Status EXECUTED in this pass. Not a pure skeleton-then-later-execute split like step.3 -- code, tests, docs, and this step file were built together per the operator's direction for this session. ## (1) Ground truth probed at drafting READ app/pal/adapter.py (the `Adapter` ABC): `capabilities()`, `run(request) -> Iterator[Response]`, `abort(run_id)`. `SUPPORTS_INTERLEAVED_ABORT`'s own docstring already names the exact case this adapter is: "a synchronous single- process design with no concurrent-abort checkpoint" -- written for ClaudeCodeAdapter (step.3) but applies letter- for-letter to a human blocked on `input()`. READ app/pal/envelope.py: `Request` (context/tool_schema/ budget/policy, the latter two still INERT per U1.1's C-2-D) and `Response` (`kind` discriminates TEXT/ TOOL_CALLS/DONE; the docstring states "DONE -- the run has concluded, no new content"). READ adapters/mock/adapter.py + adapters/claude/adapter.py (the two sibling patterns to mirror): both yield a distinct TEXT response before a final DONE; both track per-run-id status in a dict for abort() idempotency; ClaudeCodeAdapter sets `SUPPORTS_INTERLEAVED_ABORT = False` with an inline reason, the direct precedent for this adapter's own flag. READ tests/test_pal_contract.py: the shared suite's own docstring already documents TWO prior findings from adding a second real adapter (a test that turned out to be mock- specific, and a capability flag that turned out to duplicate an existing contract field) -- explicit precedent for "the contract evolves honestly when a new adapter reveals a wrongly-universal assumption," which this step needed (see finding below). READ app/cli.py's `propose_ask()`: hard-requires at least one TEXT-kind response (`if not text_responses: raise RuntimeError(...)`) to compute a diff and record egress. FOUND (a real, load-bearing design tension, not a corner case): the operator's design for this adapter -- `run()` writes the formatted request via an injectable output function, blocks on an injectable input function, and yields EXACTLY ONE `Response` with `kind == ResponseKind.DONE` wrapping the raw pasted-back text -- is narrower than both (a) envelope.py's own Response docstring ("DONE... no new content") and (b) `test_pal_contract.py`'s `test_text_response`, which asserted at least one TEXT-kind response exists, and (c) `app/cli.py`'s `propose_ask()`, which hard-requires the same. A human paste-back genuinely has no separate "content chunk" to distinguish from "the run concluded" -- unlike a streaming or single-blob-then-terminator provider, there is exactly one event. Resolved by extending (not hacking) the contract test to accept either shape honestly (see §2.4 below), and by NOT wiring `--provider human` into the `ask` CLI subcommand this step -- wiring it in without also changing `propose_ask()` would make every invocation fail with that same RuntimeError, which is worse than not exposing the flag. Reported here and in step.7.diff.txt rather than silently working around it. MISSING adapters/human/ (design.md's layout convention, §2.12, does not name it yet -- added this step); HumanRelayAdapter itself; the contract-suite registration; a dedicated unit test file; design.md's third-adapter bullet (§2.4) and new decision (§5 d13, next free id after d12); end_to_end.md's U1.5 bullet (§3) and ledger row (§11); README mention. ## (2) What U1.5 delivers 2.1 **adapters/human/adapter.py**: `HumanRelayAdapter(Adapter)`. `__init__(self, *, output_fn=print, input_fn=input)` -- constructor-injected I/O, the ONE design choice that gives this adapter the same offline/zero-network/zero-keys testability MockAdapter gets from fixtures (design.md §2.4). `_format_request()` renders a clearly delimited, human-readable block: a banner, the role-tagged context entries (`[role]\ncontent`, mirroring ClaudeCodeAdapter's `_flatten_context` shape), and the still-INERT `tool_schema`/`budget`/`policy` fields shown for visibility only when non-empty. `capabilities()`: `tools=False`, `streaming=False` (both honest -- manual text relay, no native tool-calling, no token stream), `context_window=None` (no real limit enforced BY THIS ADAPTER -- the human decides what fits; `None` states "unbounded/unknown" rather than fabricating a number), `cost_model` an explicit `{"tracked": False, "reason": ...}` marker (b3ubot makes no API call here; the AI tool's cost is the operator's own, external to this adapter's accounting -- consistent with d12's BYO-key framing). `run(request)`: generates a run_id, writes the formatted block via `output_fn`, blocks on `input_fn` for the pasted-back text, yields exactly one `Response(kind=DONE, text=, provider_meta={"provider": "human", "run_id": ...})`. `abort(run_id)`: same IDLE/RUNNING/DONE/ABORTED status-table shape as the sibling adapters, idempotent, never raises (the RUNNING branch is unreachable in today's strictly synchronous call shape, kept for symmetry and defensiveness). 2.2 **SUPPORTS_INTERLEAVED_ABORT = False**, with an inline comment citing the Adapter ABC's own docstring reasoning verbatim (no concurrent-abort checkpoint exists while blocked on `input_fn`). 2.3 **Contract suite wiring** (tests/test_pal_contract.py, extended not rewritten): `human` registered via one `pytest.param` entry, constructed with a captured-output list and a canned-string input function -- zero real interactivity. `test_text_response` EXTENDED (the genuine finding from §1): accepts a distinct TEXT-kind response (mock/claude-code's shape) OR a content- bearing terminal DONE (human's shape), asserting real content + run_id either way -- documented in the file's own docstring as a third contract-evolution finding, matching the file's established precedent for this kind of honest widening. 2.4 **tests/test_human_adapter.py** (new, adapter-internal, not part of the shared suite): `_format_request` actually contains the request's role-tagged content (not a placeholder) and the INERT fields when present/absent; `run()` calls `output_fn` exactly once with a block containing the real context, calls `input_fn` with a prompt string, and wraps the injected return value into exactly one DONE `Response` with the right `provider_meta`; `capabilities()`'s honesty (tools/streaming False, context_window None, cost_model untracked); `abort()`'s idempotent/never-raises contract on both an unknown run_id and a completed one. 2.5 **Documentation** (same commit as the code): design.md §2.4 gains a third-adapter bullet; design.md §5 gains d13 (RESOLVED, 2026-07-20, framed as a development/validation aid, NOT a replacement for d1's claude-code adapter); end_to_end.md §3 gains a U1.5 objective/done-when bullet in P1.M1; end_to_end.md §11 gains the U1.5 ledger row (done, step.7, commit hash filled in after committing per §12's discipline); README.md's "Getting started" and "The local trinity" sections mention the human adapter as the manual-relay alternative -- WITHOUT a working `--provider human` CLI example, per the §1 finding (`ask`'s `propose_ask()` cannot consume this adapter's single-DONE shape without its own change, out of scope this step). 2.6 **Closure**: end_to_end.md §11 U1.5 -> done; memory update; retro pair via step_gdiff. EXPLICITLY OUT OF SCOPE (each belongs to a later UoW, or is a deliberate non-goal this step): - Wiring `--provider human` into `app/cli.py`'s `ask` subcommand -- blocked on `propose_ask()`'s hard TEXT-response requirement, a genuine mismatch this step reports rather than papers over. -> later, if ever - Changing `propose_ask()` itself to accept a content-bearing DONE response (would affect mock/claude-code's contract too) -- a separate, deliberately-considered design change, not a quiet side effect of adding a third adapter. -> later - Any real terminal/TTY interaction in tests -- `output_fn`/ `input_fn` are ALWAYS injected in every test this step adds. -> never (by design) - Multi-turn conversational state, streaming, or tool execution for this adapter -- `capabilities()` honestly reports all three unsupported; widening any of them is a new, separately-considered step. -> later, if ever ## (3) Verification gates G1 DONE-WHEN (end_to_end.md, this step's own bullet): the SAME contract suite (tests/test_pal_contract.py) exercises `human` alongside `mock` and `claude-code`, with ZERO real interactivity (verified: no bare `input`/`print` call site in the human registration or in tests/test_human_adapter.py -- grep-checked). G2 NO PROVIDER NAME OUTSIDE adapters/ (U1.1's G1 precedent, re-run): `grep -rn -e mock -e claude -e human -e openai app/pal/` stays empty -- this step touches no file under app/pal/ at all. G3 THE MISMATCH IS REPORTED, NOT HACKED: `--provider human` does NOT appear in `app/cli.py`'s `--provider` choices (grep- verified); the reason is recorded in this file (§1), design.md §5 d13, and step.7.diff.txt -- not silently worked around by, e.g., faking a TEXT-kind response that contradicts the adapter's own documented "exactly one DONE" design. G4 CONTRACT EVOLUTION IS DOCUMENTED, NOT SILENT: the `test_text_response` change carries an inline comment explaining WHY (mirrors the file's own existing docstring precedent for two prior findings), and the file's top docstring gets a third numbered finding. G5 NO REGRESSION: the pre-existing 38 passed / 8 skipped baseline (mock + claude-code legs, all adapter-internal suites) stays fully green; `make smoke` unaffected (this step touches no file smoke.sh depends on). G6 Hygiene: b3ubot porcelain clean after each commit; retro via step_gdiff. ## (4) LOCKs C-7-A b3ubot porcelain clean after each commit. C-7-B INJECTED I/O ONLY IN TESTS: every test this step adds constructs `HumanRelayAdapter` with explicit `output_fn`/ `input_fn` overrides -- never relies on the `print`/`input` defaults (which would block real test runs on a TTY). C-7-C NO SILENT CLI WIRING: `--provider human` is not added to `app/cli.py` until `propose_ask()`'s TEXT-response requirement is itself reconciled with this adapter's single-DONE shape -- a deliberate, separately-considered change, not a quiet side effect of this step (G3). C-7-D CONTRACT CHANGES ARE EXPLAINED IN-FILE: any edit to tests/test_pal_contract.py's shared assertions carries an inline comment naming which adapter's shape motivated it (G4) -- matches this file's own established discipline. C-7-E standing locks inherited: B-1 (moot, no CCS surface here), B-3 (no disclosure/no push), B-4 (moot, no credential of any kind touches this adapter), B-5 (the formatted request block IS everything a human is asked to relay externally -- no egress policy gate exists yet, P7's job, same posture as step.3's finding), B-6 (this step's suite is T0/T1 -- deterministic, gates; no AI review tier involved). ## (5) Open questions Q-7-A Injectable I/O signature: `output_fn: Callable[[str], None] = print` / `input_fn: Callable[[str], str] = input` (LEAN -- both defaults are real stdlib callables with matching signatures, so the adapter is usable interactively with ZERO configuration, and swappable with zero-argument overrides for tests) vs a richer callback protocol (e.g. an object with separate `write()`/`read()` methods). LEAN: plain callables; revisit only if a real interactive session reveals the single-string contract is insufficient. Q-7-B Response shape: exactly one DONE response wrapping the text (LEAN, operator's explicit design -- a human paste-back has no genuine intermediate chunk to report, so a fabricated TEXT-then-DONE pair would be dishonest content duplication) vs a TEXT response followed by an empty DONE (mirrors mock/ claude-code exactly, but invents a distinction that does not exist in this adapter's actual execution model, and would make the "exactly one Response" requirement moot). LEAN: exactly one DONE response, per spec; the resulting contract-suite and CLI mismatches are handled by honest extension (test) and honest non-wiring (CLI), not by bending the adapter's shape to fit consumers built for a different model. Revisit if/when `propose_ask()` itself is generalized. Q-7-C `--provider human` CLI wiring: build it now with a `- since this adapter cannot satisfy `propose_ask()`'s TEXT-response requirement (LEAN -- do not wire this step, see C-7-C) vs changing `propose_ask()` to accept a content-bearing DONE as well (would touch the mock/claude-code path too, a larger, separately-considered change). LEAN: do not wire; report the gap (this file, design.md d13, step.7.diff.txt); whoever picks up "make `ask --provider human` real" should decide `propose_ask()`'s generalization deliberately, not as a side effect of adding a fourth adapter. ## (6) Acceptance - [x] adapters/human/__init__.py, adapters/human/adapter.py: HumanRelayAdapter -- capabilities(), run() (injectable I/O, exactly one DONE response), abort() (2.1/2.2, C-7-B). - [x] tests/test_pal_contract.py: `human` registered with fake I/O; `test_text_response` extended with an in-file-documented, adapter-shape-driven branch (2.3, G1/G3/G4/C-7-D). - [x] tests/test_human_adapter.py: format/output/input/capabilities/ abort coverage, zero real interactivity throughout (2.4, C-7-B). - [x] design.md §2.4 third-adapter bullet + §5 d13 (2.5). - [x] end_to_end.md §3 U1.5 bullet + §11 ledger row (2.5). - [x] README.md: human adapter mentioned in Getting started / The local trinity, WITHOUT a working `--provider human` CLI example (2.5, G3). - [x] `app/cli.py` NOT touched -- `--provider human` deliberately absent (G3, C-7-C). - [x] Full suite green: 52 passed, 11 skipped (up from the 38 passed / 8 skipped baseline -- +7 contract-suite instances for the human param (4 pass, 3 honest capability/flag skips) + 10 new adapter-internal unit tests, zero regressions) (G5). - [x] G2/G3/G6 grep/review-verified. - [x] Closure: end_to_end §11 U1.5 -> done; retro pair via step_gdiff (2.6, G6). ## (7) Hash backfill WORK commit (adapter + tests + docs + this file + ledger): 18b1495 GDIFF commit (step.7.gdiff.txt via scripts/step_gdiff): (HEAD) End of step.