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
            `<home>/runs/<run_id>.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
        (<n> 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/<commit>;
      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/<commit>, 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.
