step.9.txt -- b3ubot U1.4: P1 gates + retro (P1.M1) -- a live-gates script, `scripts/p1_gates.sh` (the p1_gates.sh pattern named by end_to_end.md's own U1.4 bullet), that boots the CLI, runs the SAME `ask` round trip keyless on the mock provider AND keyed on the real `claude-code` adapter (honest auto-skip when unavailable), and checks the two named hygiene properties: no provider key ever appears in gate output/logs (B-4), and the egress trail is complete -- real content, not a digest (B-5). Closes out P1.M1's final item. Step: 9 -- promote UoW **U1.4** (end_to_end.md §3, P1.M1) to execution depth: the gates script that proves P1's own exit condition live (a developer intent goes through the PAL to a real provider and back as an applied change, with the SAME arc also running keyless on the mock provider), plus the retro pair that closes P1.M1. Parent: end_to_end.md §3 U1.4 bullet ("the live-gates script (the p1_gates.sh pattern): boot, mock arc, keyed arc, hygiene legs (no key in logs; egress trail complete)") + design.md §2.10 (B-4/B-5, the two locks the hygiene legs verify literally) + the b3u sibling's own `scripts/p1_gates.sh` (style precedent named by the objective itself -- SHAPE only, not content: b3u's script has grown across ~30 of b3u's OWN steps covering billing/webhooks/accounts/etc. that have nothing to do with b3ubot's P1). UoW: U1.4 -- "P1 gates + retro". Depends on U1.1 (done, step.2, 4cbcad0), U1.2 (done, step.3, 96afa52), U1.3 (done, step.4, 659cdb6) for the PAL contract, the claude-code adapter, and the `ask` CLI round trip this script exercises. Sibling to U1.5 (done, step.7, 18b1495) and U1.6 (done, step.8, 8c58f8d) -- neither blocks nor is blocked by this step (end_to_end.md's own U1.5 bullet says so explicitly); this step closes P1.M1's LAST still-`todo` item. 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.7.) Origin: Operator directive 2026-07-20: "go with U1.4, step file and its execution" -- single pass, not a skeleton-then- later-execute split. ## (0) Status EXECUTED in this pass. Not a skeleton-then-later-execute split like step.8 -- the gates script, this step file, and the retro pair were built together per the operator's explicit direction for this step. ## (1) Ground truth probed at drafting READ end_to_end.md §3 U1.4 bullet (objective/done-when, quoted above) and §11 ledger (U1.4 row: `todo`, no step number -- confirmed before starting). READ steps/step.7.txt + step.7.diff.txt (the closest prior precedent for "a gates/smoke script IS the deliverable", and the single-pass step-file/retro FORMAT this step mirrors) and steps/step.1.txt + step.1.diff.txt (U0.4, the ORIGINAL smoke-check step -- scripts/smoke.sh's shape: a HERE-resolved root, a `die()`-style fail-fast helper, numbered `== N. ... ==` sections, one final PASS/FAIL summary line). READ scripts/smoke.sh in full (U0.4's oracle): venv-independent cppcc probe + venv-dependent mock-provider hello, fail-fast (cppcc probe failure skips the mock half entirely). READ ~/projects/b3u/scripts/p1_gates.sh lines 1-50 ONLY (the style precedent named by end_to_end.md's own U1.4 objective): `set -u`, `ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"`, a `WORK` scratch dir recreated each run, `fails=0` / `pass()`/`fail()` counters, a `== boot ==` section, a final exit code keyed off `fails`. Did NOT read past line 50 -- b3u's file has grown across ~30 of b3u's own steps (P1 through P6, billing/ webhooks/accounts/etc.); only the SHAPE transfers here, not any of that content. READ app/cli.py in full (current state, post-step.8): `_make_adapter()` now has three branches (mock/claude-code/ human); `--provider` choices are `["mock", "claude-code", "human"]`; `ask` takes positional `workspace_root rel_path intent` plus `--provider`/`--yes`/`--dry-run` flags; `propose_ask()` never writes and unconditionally records an egress row (LOCK C-4-C) before any gate decision; `apply_ask()` writes iff called; `main()`'s `--yes` skips the interactive `_confirm()` prompt entirely -- the exact flag this script needs to apply non-interactively without simulating a TTY. `--dry-run` still records egress but never writes, giving this script a clean "compute a real diff without mutating anything" probe distinct from the apply leg. READ app/egress.py in full: `record_run()` writes `/runs/.json` containing `sent_context` (the ACTUAL context list, unsummarized) and `response_summary` (`kind`/`text`/`usage` -- `text` is the full proposed content, not a hash). `data_home()` resolves `$B3UBOT_HOME`, defaulting to `~/.b3ubot` -- this script exports `B3UBOT_HOME` to a scratch path so it never touches a developer's real `~/.b3ubot`. READ adapters/claude/adapter.py + tests/test_claude_code_adapter.py + tests/test_cli.py's own `_claude_code_available, _claude_code_skip_reason = ClaudeCodeAdapter().is_available()` pattern: availability is BOTH `claude` on PATH AND `$ANTHROPIC_API_KEY` set -- not the env var alone. This machine has `claude` on PATH (`which claude` succeeds) but no `$ANTHROPIC_API_KEY` set (confirmed via `echo -n "$ANTHROPIC_API_KEY" | wc -c` -> 0, never printing the value itself) -- so a bash-only env-var check would have coincidentally matched `is_available()`'s verdict on THIS machine, but would diverge on a machine with the key set and no binary, or vice versa. The gates script therefore calls `ClaudeCodeAdapter().is_available()` directly via a `python -c` one-liner rather than re-deriving the check in bash (finding, see §5 below). READ design.md §2.10 (Security model) in full: B-4's exact wording ("never committed, never logged, never echoed into gate output") and B-5's ("nothing from a workspace reaches a provider except through the policy layer... the developer can always answer 'what left this machine, when, to whom' from the ProviderRun trail") -- the two hygiene legs quote these almost verbatim in their PASS/FAIL messages. CONFIRMED `git log --oneline -20` for exact commit-message phrasing: step.7's two-commit shape (`b3ubot step.7: U1.5 -- ...` then `b3ubot step.7: add step.7.gdiff.txt via scripts/step_gdiff`) is this step's template, NOT step.8's four-commit SKELETON/BUILD/RETRO/gdiff shape (this step is single-pass, like step.7). ## (2) What U1.4 delivers 2.1 **scripts/p1_gates.sh** (new, executable), sectioned: - `== boot ==`: venv present (`.venv/bin/python` executable) and the CLI importable (`python -c "import app.cli"`) -- adapted from smoke.sh's SHAPE (fail-fast `pass`/`fail` helpers, one diagnosis at a time), not its CONTENT: no cppcc probe here -- P1 has no CCS/b3u dependency yet (that arrives at P5), so this boot leg is deliberately lighter than a full smoke check. - `== mock arc ==`: a scratch workspace seeded with the toy `hello.py` fixture content (`print('hello')\n`), then TWO real `python -m app.cli ask` invocations against it -- `--dry-run` (asserts a real diff was computed AND the file stayed untouched) followed by `--yes` (asserts the target file was actually rewritten and an egress-trail JSON now exists under the scratch `$B3UBOT_HOME/runs/`). - `== keyed arc ==`: the SAME two-invocation round trip via `--provider claude-code`, gated on `ClaudeCodeAdapter().is_available()` (binary AND key, not the env var alone -- §1 finding). Unavailable -> one clearly labeled `SKIP` line naming the real reason string from `is_available()` itself, counted as neither pass nor fail. Available -> runs live and asserts the same round-trip properties as the mock arc. - `== hygiene: B-4 ==`: if a key was available, `grep -rlF` the literal `$ANTHROPIC_API_KEY` value across every log file this run produced under the scratch `WORK` dir -- must find zero hits. If no key was available, this leg prints an explicit `SKIP` (nothing to search for) rather than fabricating a pass. - `== hygiene: B-5 ==`: greps the mock (and, if it ran, the keyed) egress-trail JSON for the LITERAL intent string and the literal original/proposed file content -- proving `record_run()`'s "the actual content" claim live, on disk, not only via the unit-test suite. - Final summary: `fails` counter, `P1 GATES PASS`/`P1 GATES FAIL ( failure(s))`, exit 0/1 -- mirrors b3u's own counter/exit pattern. 2.2 **Verified live this step**: keyless (mock) arc PASS in full; keyed (claude-code) arc honestly SKIPPED -- `$ANTHROPIC_API_KEY` is genuinely unset in this dev environment (confirmed without ever printing the value), which is the documented, EXPECTED correct outcome per this step's own origin note, not a gate failure or a thing to work around. 2.3 **Closure**: end_to_end.md §11 U1.4 -> done/step.9/; retro pair (this file + step.9.diff.txt) via `scripts/step_gdiff` (the SHARED cross-repo script at `v3_claude/scripts/step_gdiff`, NOT a b3ubot-local copy -- the step.8 finding, confirmed again here rather than rediscovered). EXPLICITLY OUT OF SCOPE (named here per the operator's brief, not silently built): - P2 (the ledger engine) -- U2.1-U2.4 are untouched; this gate exercises no ledger machinery, there is none yet. -> later - P3 (the orchestrator) -- no state machine, no DAG scheduling, no ratify/verify/retro loop is exercised by this script; `ask` is still the U1.3 "SIMPLIFIED precursor to the real orchestrator state machine", named honestly as such in app/cli.py's own docstring (LOCK C-4-D, inherited). -> later - P5 (the CCS oracle plane / b3u client) -- no cppcc invocation, no b3u API call, anywhere in this script (the boot leg's own §2.1 note above is the concrete form of this). -> later - A real `--provider human` leg in these gates -- U1.5/U1.6 already have their own dedicated contract-suite + `test_cli.py` coverage with injected I/O; this script's job per end_to_end.md's own wording is "ONE round trip... with the SAME arc running keyless on the mock provider" (P1's exit condition, quoted verbatim) plus the keyed arc named explicitly in U1.4's own bullet -- human is a THIRD, additive adapter (step.7's own framing) outside that two-arc scope. -> never, this step ## (3) Verification gates G1 BOOT: `bash scripts/p1_gates.sh` with a present venv and an importable `app.cli` reaches the mock-arc section (verified live: boot leg PASS/PASS). G2 MOCK ARC GREEN: both the `--dry-run` and `--yes` mock invocations exit 0; the dry-run diff contains the expected proposed content while leaving the file untouched; the apply leg actually rewrites the target file; an egress-trail JSON exists afterward (verified live: 6/6 PASS this leg). G3 KEYED ARC HONEST: with `$ANTHROPIC_API_KEY` unset on this machine, the keyed arc prints exactly one `SKIP` line naming `ClaudeCodeAdapter().is_available()`'s own real reason string, contributes to neither the pass count nor the `fails` counter, and the script's overall exit code is unaffected by it (verified live: exit 0 with the leg skipped). G4 HYGIENE B-4: when a key IS available, no gate log under the scratch `WORK` dir contains the literal key value (`grep -rlF`); when no key is available (this run), the leg prints an explicit `SKIP`, never a fabricated `PASS` (verified live: SKIP, correctly not claimed as PASS). G5 HYGIENE B-5: the mock egress-trail JSON, inspected live via `grep`, contains the literal intent string and the literal original/proposed file content -- not a hash or digest (verified live: PASS). G6 NO REGRESSION: the pre-existing 55 passed / 11 skipped baseline (post-step.8) is unaffected -- this step adds no test file and touches no file under `app/`, `adapters/`, or `tests/` (verified live: `pytest -q` -> `55 passed, 11 skipped`, unchanged). G7 Hygiene: b3ubot porcelain clean after each commit; retro via step_gdiff. ## (4) LOCKs C-9-A b3ubot porcelain clean after each commit. C-9-B SCRATCH ISOLATION: every workspace and every `$B3UBOT_HOME` this script touches lives under `/tmp/b3ubot_p1_gates` (recreated at the top of each run) -- a developer's real `~/.b3ubot` egress history and any real workspace are never read or written by this script. C-9-C NO FABRICATED PASSES: an unavailable keyed leg or an unset-key hygiene leg prints `SKIP` with a real reason, never a `PASS` standing in for work that did not happen (G3/G4's concrete form; the operator's own framing for this step -- "that's fine, the keyed leg's honest skip IS the expected/correct outcome here, not a failure to work around"). C-9-D standing locks inherited: B-1 (moot, no CCS surface here -- this script never invokes cppcc, §2.1's boot-leg note), B-3 (no disclosure/no push), B-4 (the whole point of the B-4 hygiene leg, G4), B-5 (the whole point of the B-5 hygiene leg, G5), B-6 (this step's gates are T0/T1 -- deterministic shell assertions; no AI review tier involved). ## (5) Open questions Q-9-A Keyed-arc availability check: re-derive `is_available()`'s binary-AND-key logic in bash (matches b3u's own p1_gates.sh style of inline bash checks) vs call `ClaudeCodeAdapter().is_available()` directly via a `python -c` one-liner (LEAN -- this machine happens to have `claude` on PATH with no key set, so a bash-only env-var check would have coincidentally produced the same verdict here, but would diverge from the adapter's OWN truth on a machine with a different combination; re-deriving adapter logic in a second language is also a duplication-drift risk every future change to `is_available()` would need to remember to mirror). LEAN: call the adapter directly; resolved and built this way (§1 finding, §2.1 doc). Q-9-B Double live API cost for the keyed arc: run BOTH `--dry-run` and `--yes` against the real `claude-code` adapter (mirrors the mock arc's own two-invocation shape exactly, matching the objective's "the SAME round trip") vs a single `--yes`-only invocation to halve live API cost when a key IS present. LEAN: mirror the mock arc exactly (two invocations) -- this leg only executes on a machine where the operator has deliberately supplied a key, spending real API cost is an accepted, opt-in cost of proving the FULL round trip rather than a partial one; not exercised this run regardless (key unset), so the tradeoff is theoretical until a future run with a key set. ## (6) Acceptance - [x] scripts/p1_gates.sh: boot / mock arc / keyed arc / B-4 leg / B-5 leg / summary, executable (2.1, G1-G6). - [x] Mock arc verified live: 6/6 PASS (dry-run diff+no-write, apply exit+write+egress) (2.2, G2). - [x] Keyed arc verified live: honest SKIP, `ClaudeCodeAdapter().is_available()` -> False, `$ANTHROPIC_API_KEY not set` reason printed verbatim, zero impact on exit code (2.2, G3, C-9-C). - [x] B-4 hygiene leg: honest SKIP (no key to check for leakage this run) -- not a fabricated PASS (G4, C-9-C). - [x] B-5 hygiene leg: PASS, mock egress record's real content verified live via grep (G5). - [x] Full suite unaffected: 55 passed / 11 skipped, unchanged from the step.8 baseline (G6). - [x] end_to_end.md §11: U1.4 -> done/step.9/, same commit as the work (2.3, G7). - [x] Retro pair: this file + step.9.diff.txt + step.9.gdiff.txt via the SHARED `v3_claude/scripts/step_gdiff` (2.3, G7). ## (7) Hash backfill WORK commit (gates script + this file + retro narrative + ledger): 2cf5c0f GDIFF commit (step.9.gdiff.txt via scripts/step_gdiff): (HEAD) End of step.