step.15.txt -- b3ubot U3.2: Tool surface + policy gates (allowlist
               shell, git ops, approval for destructive -- the
               no-`--force` posture). Grows U1.3's minimal app/tools.py
               into the app/tools/ package and delivers the REST of
               design.md §2.7's surface, standalone: directory listing,
               allowlist shell execution, git status/diff/add/commit,
               destructive refuse-stubs, oracle invocation V0, and the
               unconditional tool-call trail. Stops here -- does NOT
               continue into U3.3 or any later P3 UoW.

Step:     15
Parent:   end_to_end.md §5 U3.2 bullet verbatim ("Tool surface + policy
          gates (allowlist shell, git ops, approval for destructive --
          the no-`--force` posture)"). design.md §2.7 (the tool
          surface) is the section this step implements directly; §2.10
          (standing locks B-1..B-6) and §5 d6 (egress allowlist LEAN --
          consistent-with, NOT resolved here) are the policy frame.
          app/tools.py (U1.3, C-4-B) is the module family this extends;
          app/egress.py (U1.3) is the trail-precursor framing precedent
          this step's tool-call trail mirrors; app/config/loader.py
          (U2.2, step.11) is the config mechanism the allowlist rides;
          scripts/p1_gates.sh (U1.4, step.9) is the one real, runnable
          gate script that makes oracle-invocation V0 honest.
UoW:      U3.2
Date:     2026-07-21
Origin:   Operator directive 2026-07-21 (same dispatch shape as
          step.14's): draft AND execute steps/step.15.txt for U3.2 in a
          single pass (the step.7/9/10/11/14 convention, NOT a
          skeleton-split), with the SAME explicit scope boundary -- stop
          after U3.2, do not continue into U3.3 or any later UoW; the
          operator is directing P3 one UoW at a time with per-UoW
          checkpoints (an earlier agent this session autonomously
          overran its dispatched scope; this rule exists because of
          that).

## (0) Status

EXECUTED in this pass. Not a skeleton-then-later-execute split --
app/tools/ (the package: __init__.py, errors.py, workspace.py,
shell.py, gitops.py, trail.py), the config.txt allowlist key, the four
tool test files + tests/conftest.py, docs/tool_surface.md, the
design.md/end_to_end.md edits, and this step file were all built
together, per the operator's explicit "draft AND execute (single pass)"
direction. Per the operator's explicit scope boundary (Origin, above),
this session STOPS after U3.2 -- U3.3 (PAL-drafted skeletons) is the
next UoW but is NOT started here.

## (1) Ground truth probed at drafting (outputs -> named writers)

    READ    design.md §2.7 in full (located by heading text, NOT by
            line number -- the operator's own brief warned the file has
            grown and old line references have drifted; confirmed: §2.7
            sits at line 269 today). The delivered surface maps 1:1
            onto its four bullets: file read/write + directory listing;
            shell execution (allowlist + workspace-scoped, timeouts,
            output capture); git operations (status/diff/add/commit on
            the working branch); oracle invocation (run a named gate
            tier). Plus the closing paragraph: every call policy-gated,
            logged, replayable; destructive ops (delete, history
            rewrite, pushes) require explicit human approval -- the
            b3u launcher's "refuse with a named next action, no
            --force" posture.
    READ    design.md §2.10 (standing locks B-1..B-6) and §5 d6 (egress
            allowlist, LEANING -- "nothing leaves unless named").
            Nothing here resolves d6 (it is an EGRESS-policy lean; this
            step's allowlist governs LOCAL execution) -- but the shape
            is deliberately consistent with it: nothing runs unless
            named, named in exactly one visible place.
    READ    app/tools.py (U1.3) in full: read_file/write_file,
            _resolve_within, WorkspaceError, LOCK C-4-B (unconditional
            scope enforcement, no flag bypasses). Its own docstring
            names U3.2 as the step that builds the rest ("shell
            execution, git operations, directory listing, oracle
            invocation, delete") -- this step is that named extension,
            not a parallel invention.
    READ    app/egress.py (U1.3) in full: record_run()'s unconditional
            posture (LOCK C-4-C), data_home()'s $B3UBOT_HOME
            convention, and the docstring's exact interim framing --
            "the named file-based precursor the U1.3 done-when itself
            asks for, not a placeholder hiding a gap (design.md §1.2,
            honest interims)". The tool-call trail reuses that framing
            verbatim in spirit and imports data_home() DIRECTLY
            (C-14-E: no second data home).
    READ    app/orchestrator/ (U3.1, step.14): the execution DAG's
            tool_call node kind is what this surface will one day serve
            -- but compiler.py sets `status = pending` and nothing
            schedules; confirmed NO wiring point exists yet. U3.2
            delivers the surface STANDALONE; wiring is U3.5, and no
            orchestrator file is touched in this step.
    READ    app/config/loader.py + config.txt (U2.2, step.11): the
            general `key = "value"` reader ("later we may add more
            b3ubot/app config parameters" -- operator, step.11). The
            shell allowlist is exactly such a parameter; the
            `shell_allowlist` key rides this existing mechanism, and
            the default-duplicated-in-code shape follows the
            banner/DEFAULT_BANNER_PATH precedent already in that file.
    GREP    b3u sibling scripts/b3u_local.sh for the cited launcher
            posture: the literal wording lives in cmd_start()'s
            preflight comment -- "refuse with the NAMED fix; nothing is
            created before every check passes" -- with die() messages
            that each name the concrete next action ("install uv ...,
            then re-run", "run: make -C ..."). Every refusal message in
            this step's surface follows that message shape: what was
            refused, that nothing happened, and the named next action.
    READ    scripts/p1_gates.sh (step.9) header: a real, runnable,
            bash, exit-code-is-the-verdict gate script (exits 1 on
            FAIL by design) -- the concrete artifact that makes
            oracle-invocation V0 honest, and the reason a non-zero
            exit must be REPORTED data, not a raised exception.
    READ    tests/test_tools.py (U1.3) + tests/test_egress.py +
            tests/test_cli.py for the injected-I/O discipline (C-7-B
            lineage) and the B3UBOT_HOME handling patterns. Confirmed
            NO conftest.py existed, and that test_cli.py sets
            B3UBOT_HOME in only SOME tests -- see the LIVE FINDING
            below (what forced tests/conftest.py into scope).
    READ    tests/test_ledger_table_roundtrip.py::
            test_structural_round_trip_after_mutating_a_row + step.14
            .diff.txt finding 2: the test's hardcoded todo-row example
            (U3.2, as of step.14) breaks the moment THIS step's own
            ledger dogfooding moves U3.2 to done -- the THIRD
            consecutive occurrence, and step.14's retro explicitly
            named the remedy for whoever hit it a third time (pick by
            STATUS query). Applied here -- see (2.9).
    READ    app/ledger/parser.py's schema constraints (KNOWN_HEADER_
            FIELDS, section-(0)-must-be-'Status', free section titles
            beyond (0)) -- because THIS file becomes corpus data the
            moment it lands (the parser's/emitter's self-inclusive
            corpus parametrization, +2 tests, the steps 10-14 growth
            pattern); its shape mirrors step.14.txt's exactly.
    LIVE FINDING (folded into (2.8), not silently worked around):
            making trail recording UNCONDITIONAL means the test suite
            ITSELF appends trail entries on every tools call -- and
            tests/test_tools.py (7 U1.3 tests) plus several
            tests/test_cli.py arcs call the tools layer WITHOUT setting
            $B3UBOT_HOME, so a plain `pytest` run would have appended
            dozens of entries to the developer's REAL
            ~/.b3ubot/tool_calls.jsonl -- test noise polluting a live
            audit trail. Surfaced immediately by the first full-suite
            run (a list_dir test also saw the data home appear inside
            its own workspace listing when the first conftest draft
            nested the home under tmp_path). Fixed structurally:
            tests/conftest.py (new) with an autouse fixture pointing
            $B3UBOT_HOME at a per-test tmp tree OUTSIDE the test's own
            tmp_path; per-test monkeypatch overrides (test_egress.py's
            env-resolution probes) still win.
    CONFIRMED `git log --oneline -30` for exact commit-message phrasing
            (the `b3ubot step.N: Ux.y -- summary` / `b3ubot step.N: add
            step.N.gdiff.txt via scripts/step_gdiff` two-commit shape,
            step.14's own precedent, f66b53e/b1a3071).
    CONFIRMED baseline: full suite 329 passed / 11 skipped before any
            edit (matches step.14's own reported final).

## (2) What U3.2 delivers

  2.1 **app/tools/ becomes a package** (module -> package growth, the
      DECISION the operator's brief asked to be justified): U1.3's
      single app/tools.py held 2 operations in 46 lines; §2.7's full
      surface is ~6 concerns and would have made one module a grab-bag.
      app/ledger/ (5 modules) and app/orchestrator/ (4) already
      established module-per-concern packages as this repo's shape for
      exactly this growth; app/tools/__init__.py re-exports the entire
      public surface, so every existing call site (`from app.tools
      import WorkspaceError`, `tools.read_file(...)` in app/cli.py,
      tests/test_tools.py's imports) works UNCHANGED -- verified by the
      U1.3 tests passing without edits. app/cli.py: zero changes.
  2.2 **app/tools/errors.py**: the exception family (the app/ledger/
      errors.py shape). WorkspaceError MOVED here unchanged (identity,
      message, C-4-B contract intact); new: ToolPolicyError (base for
      policy refusals), ShellNotAllowlistedError, ApprovalRequiredError
      (destructive), GitError (a failed non-destructive git op --
      RAISED, unlike shell exits, because git ops' results are data
      with no usable partial form).
  2.3 **app/tools/workspace.py**: read_file/write_file moved intact;
      NEW list_dir (workspace-scoped via the same _resolve_within,
      sorted names, dirs suffixed '/'); NEW delete_file as the FIRST
      destructive refuse-stub (see 2.5's posture decision -- delete is
      on §2.7's own destructive list). Scope check runs FIRST, so an
      out-of-workspace delete refuses as the more fundamental
      WorkspaceError, not merely "unapproved".
  2.4 **app/tools/shell.py**: `run_shell(root, argv, *, timeout=120,
      cwd_rel=".")` -- the NAMED allowlist (STARTING SET, justified:
      bash for gate scripts, pytest as the T0-oracle-in-waiting, make
      for this repo's Makefile, python/python3 for the venv interpreter
      whose basename is `python` plus the system one; git deliberately
      EXCLUDED so gitops.py's operation-level policy cannot be
      bypassed; nothing network-shaped). WHERE IT LIVES (decision):
      hardcoded frozen default + `shell_allowlist` key in app/config/
      config.txt via loader.py's existing mechanism (step.11's
      precedent; one place the operator already knows) -- the override
      REPLACES the default so it can also NARROW. Matching on the
      BASENAME of argv[0] (allowlists name executables, not paths);
      argv is a vector, no `sh -c` string splicing surface. Mandatory
      positive timeout (kill + timed_out=True + captured partial
      output on expiry). NON-ZERO EXIT REPORTED NOT RAISED (decision,
      justified): p1_gates.sh's exit code IS its verdict, and U3.5's
      scheduler needs verdicts as data; exceptions are reserved for
      policy refusals, which mean the call never ran. Refusal message
      names the fix verbatim-style: add '<exe>' to the shell_allowlist
      key in app/config/config.txt.
  2.5 **app/tools/gitops.py**: git_status (--porcelain) / git_diff
      (optional rel_path + staged=) / git_add (named paths only -- no
      add-everything verb) / git_commit (message-as-data, returns the
      new hash) -- all on the CURRENT working branch; no branch-switch
      verb exists. TWO-LAYER SCOPE: rel paths through _resolve_within
      AND the root must itself be a git repo top (root/.git exists),
      refusing git's own walk-up behavior (a nested non-repo workspace
      would otherwise silently stage into its ENCLOSING repository --
      found by reasoning about git's discovery rules, proven by test).
      DESTRUCTIVE POSTURE (decision, justified): push / history
      rewrite / branch deletion exist as NAMED REFUSE-STUBS (git_push,
      git_rewrite_history, git_delete_branch) raising
      ApprovalRequiredError unconditionally -- present-but-refusing
      chosen over absent-by-design because §2.7's own wording
      ("destructive operations ... REQUIRE EXPLICIT HUMAN APPROVAL")
      describes gated members of the surface, not non-members; stubs
      make every attempt a trail-recorded event (exactly what an audit
      trail is for) and refuse with the named next action ("the
      approval flow is U3.4's territory"; for this repo push is
      additionally forbidden outright, B-3). The approval FLOW itself
      is NOT built -- U3.4's territory, no prompt exists anywhere in
      this step.
  2.6 **app/tools/shell.py::run_gate(root, name, *, timeout=600)** --
      oracle invocation V0, the brief's proposed reading CONFIRMED:
      T0/T1 tiers don't exist (U3.6), but scripts/p1_gates.sh (step.9)
      is a real, runnable gate script, so "run a named gate" = run
      scripts/<name> under the SAME allowlist/timeout/capture
      machinery (via bash, itself allowlist-checked -- gates get no
      side door). `name` must be bare (no '/', no '..'); unknown names
      refuse and NAME the known gates. Nothing more -- tiers, the
      verification DAG, and repair loops stay U3.6.
  2.7 **app/tools/trail.py** -- the unconditional tool-call trail.
      WHERE IT LANDS (decision, justified): a SIBLING of app/egress.py,
      NOT an extension -- the egress trail answers B-5's "what left
      this machine" and tool calls are local actions that never leave;
      folding them in would muddy the one file whose completeness IS
      the B-5 answer. Same data_home() imported directly (C-14-E, no
      second home), different file: <data_home>/tool_calls.jsonl,
      append-only JSONL (ts/tool/args-as-given-workspace-relative/
      outcome/exit_code/detail; outcome vocabulary ok|nonzero|timeout|
      refused|error), egress.py's exact "named file-based precursor of
      the Track B ProviderRun row, not a placeholder hiding a gap"
      framing restated in its docstring. RECORDING IS UNCONDITIONAL
      (LOCK C-15-B, the C-4-C posture): every call through the surface
      records -- successes, non-zero exits, timeouts, errors, AND
      refusals (refusals are exactly what an audit trail is for);
      implemented once in trail.logged_call() and used by every public
      op, including U1.3's read_file/write_file retroactively ("every
      tool call is logged" means every). read_trail() is the audit/
      replay reader that makes "replayable" a tested property.
  2.8 **tests/conftest.py** (new): autouse per-test $B3UBOT_HOME
      isolation -- REQUIRED once recording became unconditional (the
      LIVE FINDING in (1): a plain pytest run would otherwise pollute
      the developer's real ~/.b3ubot trail with test noise). Also
      restates the C-7-B lineage: U3.2 introduces NO prompt anywhere,
      so nothing needed injectable input this step.
  2.9 **tests** (51 new): test_tools.py +7 (list_dir x4, delete_file
      stub x3 -- U1.3's 7 untouched and passing); test_tools_shell.py
      (20: permitted runs + capture + cwd scoping, allowlist refusal
      with the named fix, basename-not-path matching, config override
      REPLACES/narrows + absent-key fallback, empty-argv/non-positive-
      timeout/cwd-escape refusals, timeout kills a hanging sleep in
      <10s, run_gate pass/fail-verdict/workspace-cwd/unknown-name/
      path-shaped-name cases + the real p1_gates.sh nameability);
      test_tools_git.py (11: scratch-repo fixture -- git init in
      tmp_path, NEVER this repo -- status/diff/add/commit round-trip
      with hash shape + clean-tree checks, non-repo and nested-
      workspace walk-up refusals, add-escape refusal, empty-message/
      empty-paths/nothing-staged errors, all three destructive stubs
      refuse + history-untouched proof + the no---force message);
      test_tools_trail.py (12: ordering, JSONL shape, shared data
      home, refused-workspace-escape/refused-allowlist/refused-
      destructive all RECORDED, nonzero exit code recorded, timeout
      recorded, run_gate logged once under its own name, no-disable-
      parameter introspection (C-15-B), empty-trail-reads-empty).
      PLUS the thrice-broken tests/test_ledger_table_roundtrip.py::
      test_structural_round_trip_after_mutating_a_row converted from
      hardcoded-id to STATUS-QUERY pick (first row where status ==
      'todo') -- step.14 retro finding 2's own named remedy for
      whoever hit it a third time, which this step's own dogfooding
      (2.11) did; the break class is now impossible, not merely
      fixable, and the test still mutates a REAL live-table row.
  2.10 **docs/tool_surface.md** (new): the live-inventory write-up --
      package diagram (ASCII), the allowlist table + config mechanics,
      the posture summary (timeouts, exit-as-data, git two-layer
      scope, destructive stubs, trail format), test-coverage map.
  2.11 **design.md §2.7**: a short *Delivered state* paragraph appended
      (the section's four bullets and closing paragraph untouched --
      they still describe the design accurately); it names the two
      honest interims (trail = file-based ProviderRun precursor until
      Track B; approval = unconditional refuse-stubs until U3.4).
      **end_to_end.md**: §5 U3.2 row gains the DONE step.15 clause in
      U3.1's exact row style; §11 ledger row -> done/step.15 THROUGH
      the existing `ledger` CLI (dogfooding: `ledger transition U3.2
      active --step step.15` then `ledger close U3.2 PENDING`; hash
      backfilled in the gdiff commit, step.14's shape).

EXPLICITLY OUT OF SCOPE (named here per the operator's brief, not
silently built -- and NOT continued into after this step, per the
operator's explicit stop-here scope boundary):
  - U3.3 SKELETON flow: PAL-drafted step files.                -> next
  - U3.4 Ratify gate + amendment loop -- INCLUDING the interactive
    destructive-op approval UI; today's stubs refuse
    unconditionally and prompt for nothing.                    -> later
  - U3.5 EXECUTING loop / scheduler -- wiring these tools to the
    execution DAG's tool_call nodes. The surface is standalone. -> later
  - U3.6 VERIFYING: real T0/T1 gate tiers, the verification-kind
    DAG, the bounded repair loop -- run_gate stays a named-script
    runner until then.                                          -> later
  - U3.7/U3.8, Track B / P4 (the real ProviderRun row).        -> later
  - Resolving d6: the egress policy remains a LEAN; this step's
    allowlist governs local execution and is merely consistent
    with d6's shape.                                            -> open

## (3) Verification gates

    G1  All U1.3 tests in tests/test_tools.py pass UNCHANGED against
        the package (the module-to-package growth broke no call site;
        app/cli.py needed zero edits) -- verified live.
    G2  tests/test_tools_shell.py 20/20 PASS live: allowlisted runs
        capture stdout/stderr with exit-code-as-data; 'curl' refuses
        naming shell_allowlist + app/config/config.txt; the config
        override replaces AND narrows; a 30s sleep under timeout=0.5
        is killed and reported timed_out in well under 10s; run_gate
        runs a named scripts/ gate, reports a failing gate's exit-1
        verdict without raising, and refuses path-shaped names.
    G3  tests/test_tools_git.py 11/11 PASS live on a SCRATCH repo
        (git init under tmp_path -- this repo's .git untouched):
        status/diff/add/commit round-trip with a 40-hex hash and a
        clean tree after; the nested-workspace walk-up refusal; all
        three destructive stubs refuse with the U3.4-naming,
        no---force message and touch nothing (HEAD compared
        before/after).
    G4  tests/test_tools_trail.py 12/12 PASS live: every outcome kind
        recorded (ok, nonzero+exit_code, timeout, refused x3 flavors);
        refusals carry the refusal text in detail; run_gate logs once
        under its own name; the no-disable-parameter introspection
        gate (C-15-B); JSONL one-object-per-line under the SHARED
        data_home().
    G5  design.md §2.7's own closing sentence, LITERALLY: every tool
        call policy-gated (G2/G3's refusal legs), logged (G4),
        replayable (read_trail round-trips every recorded entry);
        destructive ops require approval (G3's stubs -- 'require'
        realized as unconditional-refusal-until-U3.4, the honest
        interim named in the delivered-state paragraph).
    G6  Full suite before this step's edits: 329 passed / 11 skipped
        (confirmed live, matching step.14's reported final). After:
        380p/11s pre-step-file; 382p/11s once this step.15.txt lands
        in the corpus (+51 tool tests, +2 self-inclusive corpus
        pickups -- the steps 10-14 growth pattern). Zero regressions;
        ONE pre-existing test intentionally changed (not loosened):
        the thrice-broken roundtrip example converted to the status-
        query pick its own history demanded (2.9) -- it still
        exercises a real live-table row every run.
    G7  Scope boundary held: `grep -rn "U3\.[3-8]" app/tools/` -- every
        hit is a scope-boundary annotation (naming U3.4/U3.5/U3.6 as
        the owner of what is NOT built here), never an implementation:
        no PAL-drafting code, no prompt/approval UI (no input() call
        anywhere in app/tools/), no scheduler wiring (no orchestrator
        import anywhere in app/tools/), no gate-tier vocabulary --
        confirmed live before committing. This session stops here;
        U3.3 is not started.
    G8  §11 ledger row updated THROUGH the existing `ledger` CLI
        (transition -> active --step step.15, close -> done/PENDING),
        against the REAL end_to_end.md -- dogfooding, step.13/14's
        precedent.
    G9  b3ubot porcelain clean after each commit; retro pair via the
        SHARED v3_claude/scripts/step_gdiff (steps/step.15.gdiff.txt);
        exactly 2 commits (work + gdiff-with-hash-backfill-folded-in),
        step.7/9/10/11/13/14's precedent.

## (4) LOCKs

    C-15-A  b3ubot porcelain clean after each commit.
    C-15-B  TOOL-CALL RECORDING IS UNCONDITIONAL (the C-4-C posture,
            applied to the tool surface): every call through app/tools/
            -- successes, non-zero exits, timeouts, errors, AND
            refusals -- lands in the trail; no caller, flag, or future
            extension may skip it (G4's introspection gate is the
            regression detector).
    C-15-C  NO SILENT SCOPE CREEP: U3.3-U3.8 named out of scope in (2)
            and confirmed not started (G7); the operator's explicit
            "stop after U3.2" direction is honored. d6 remains a LEAN.
    C-15-D  DESTRUCTIVE MEANS REFUSED, NOT ABSENT, NOT PROMPTED:
            delete_file / git_push / git_rewrite_history /
            git_delete_branch raise ApprovalRequiredError
            unconditionally and touch nothing; NO interactive approval
            path exists in this step (that is U3.4's lock to replace,
            not extend around). No input() anywhere in app/tools/.
    C-15-E  ONE POLICY HOME PER CONCERN: the shell allowlist lives in
            code-default + app/config/config.txt's `shell_allowlist`
            key ONLY (no env var, no per-call allowlist= parameter --
            a call-site override would be a policy bypass); git's only
            path is gitops.py's named operations (git deliberately not
            on the shell allowlist).
    C-15-F  C-4-B inherited intact: _resolve_within guards EVERY
            path-taking op in the package (old and new), and the
            git root must itself be a repo top (no walk-up).
            C-14-E inherited: data_home() imported from app/egress.py,
            never reimplemented -- one data home for the whole repo.
    C-15-G  standing locks inherited: B-3 (no disclosure/no push --
            and git_push refuses unconditionally anyway); B-6
            (deterministic pytest oracles gate this step); B-4/B-5
            untouched (no provider call, no egress-path change; the
            trail is local-actions-only by design).

## (5) Open questions

    Q-15-A  Should the trail rotate/segment (per-day files, size caps)?
            LEAN: not yet -- volumes are tiny (a full suite run writes
            a few hundred small lines into a throwaway test home), and
            the real answer arrives with Track B's ProviderRun row,
            which replaces this file's role wholesale. Revisit if a
            long-lived dev home's trail measurably grows before P4.
    Q-15-B  Should run_shell env-filter (pass a scrubbed environment
            rather than inheriting)? B-4 says credentials are env-only
            -- inherited env is how the venv/pytest legitimately work,
            but it also hands any child process the provider keys.
            LEAN: revisit at U3.5 (when PROVIDER-DRIVEN tool_call
            nodes, rather than the operator's own gates, start running
            commands) -- that is the moment inherited env becomes a
            provider-reachable surface and B-5-adjacent; today every
            caller is the developer or the test suite.
    Q-15-C  Should git_commit enforce the repo's own message
            conventions (the step-trail phrasing)? LEAN: no --
            conventions are the orchestrator's/operator's voice, not
            the tool layer's; the tool stays mechanism (C-15-E's
            one-concern-per-home logic applied to prose).

## (6) Acceptance

    - [x] app/tools/ package; every U1.3 call site works unchanged
          (app/cli.py zero edits) (2.1, G1).
    - [x] list_dir + delete_file refuse-stub, C-4-B-scoped (2.3, G1).
    - [x] run_shell: named allowlist (default bash/make/pytest/python/
          python3; config.txt `shell_allowlist` override), mandatory
          timeouts, capture, exit-as-data, named-fix refusals (2.4,
          G2).
    - [x] gitops: status/diff/add/commit on a verified repo root;
          push/rewrite/branch-delete as unconditional refuse-stubs
          naming U3.4 (2.5, G3, C-15-D).
    - [x] run_gate: named-gate-script V0 under the same machinery
          (2.6, G2).
    - [x] trail.py: unconditional JSONL trail, refusals included,
          sibling-of-egress, read_trail replay reader (2.7, G4,
          C-15-B).
    - [x] tests/conftest.py hermetic $B3UBOT_HOME (the live finding)
          (2.8).
    - [x] 51 new tests across 4 files; thrice-broken roundtrip test
          converted to status-query pick; zero regressions
          (329p/11s -> 380p/11s -> 382p/11s with this file) (2.9, G6).
    - [x] docs/tool_surface.md; design.md §2.7 delivered-state note;
          end_to_end.md §5 row + §11 ledger via the `ledger` CLI
          (2.10, 2.11, G8).
    - [x] Scope boundary held: U3.3-U3.8 not started, d6 unresolved
          (G7, C-15-C) -- this session stops after U3.2.
    - [x] Retro pair: this file + step.15.diff.txt + step.15.gdiff.txt
          via the SHARED scripts/step_gdiff (G9).
    - [x] Exactly 2 commits (work + gdiff-with-backfill), not 3 (G9).

## (7) Hash backfill
WORK commit (app/tools/ + config + tests + docs + design.md/end_to_end.md + this file):  e6544fa
GDIFF commit (step.15.gdiff.txt via scripts/step_gdiff):  (HEAD)

End of step.
