step.4.txt -- b3ubot U1.3: one-round-trip CLI (P1.M1) -- `b3ubot ask`:
               intent -> context assembly -> PAL -> a proposed new file
               version -> a human-gated apply -> result, plus the tool
               surface's MINIMAL core (file read/write, workspace-
               scoped -- NOT P3's full surface) and B-5's first,
               FILE-based egress trail (no ProviderRun DB row exists
               yet, P4's job).

Step:     4  -- promote UoW **U1.3** (end_to_end.md §3, P1.M1) to
                execution depth: the first piece of b3ubot a human can
                actually run end to end -- one file, one intent, one
                proposed change, one gate, one write.
Parent:   end_to_end.md §3 U1.3 ("One-round-trip CLI"; objective =
                "b3ubot ask (working title): intent -> context
                assembly -> PAL -> proposed diff -> gated apply ->
                result. The tool surface's minimal core (file read/
                write, workspace-scoped) lands here."; done-when =
                "the arc completes on a toy workspace via mock AND via
                the real adapter; every workspace byte that left is in
                the ProviderRun trail (B-5's first, file-based form)")
                + design.md §2.6 (workspace manager) + §2.7 (tool
                surface -- the FULL version, P3's job; this step gets
                only its "minimal core") + §2.10 B-5 (egress policy)
                + §2.12 (per-developer data home, ~/.b3ubot).
UoW:      U1.3 -- "One-round-trip CLI". Depends on U1.1 (done, step.2)
                + U1.2 (done, step.3). Sole blocker of U1.4 (P1 gates
                + retro, which closes the whole P1 phase).
Date:     2026-07-18  (SKELETON -- not executed. Execute on explicit request.)
Origin:   User directive 2026-07-18: "execute step for U1.3" -- same
                draft-and-execute-in-one-pass rhythm as step.3.

## (0) Status

SKELETON. Nothing built. $ANTHROPIC_API_KEY remains unset in this dev
environment (the operator's Anthropic Console payment attempts failed
twice, 2026-07-18 -- paused, not abandoned). Exactly like step.3, the
"via the real adapter" done-when leg will honestly auto-skip in THIS
session; the mock leg is where the arc gets fully proven live.

## (1) Ground truth probed at drafting

    EXISTS (the PAL contract, U1.1): Request/Response with
            `context: list[dict]`, `Response.kind` in
            {TEXT, TOOL_CALLS, DONE}. NO "diff" response kind exists.
            `tool_schema` is still INERT (C-2-D) -- a TOOL_CALLS-based
            "propose a file edit as a tool call" design is explicitly
            out of reach this step; it would mean wiring real tool
            execution ahead of schedule.
    EXISTS (both adapters, U1.1/U1.2): MockAdapter routes on the
            FIRST context entry's `content` string (exact match,
            adapters/mock/fixtures.py); ClaudeCodeAdapter flattens
            ALL context entries into one prompt string regardless of
            role order (`_flatten_context`, step.3) -- so entry ORDER
            only matters for the MOCK's routing, not for what either
            adapter actually sends externally.
    EXISTS (§2.6 workspace manager): "workspace writes happen only
            through the gated tool surface (§2.7) in EXECUTING state."
            EXECUTING is an orchestrator state (§4) that does not
            exist until P3 -- this step's CLI has no state machine,
            just a direct read -> assemble -> call -> gate -> write
            flow. Named explicitly as a SIMPLIFIED precursor, not a
            silent skip of the real state machine (honest interim,
            §1.2).
    EXISTS (§2.7, the FULL tool surface -- P3's job, U3.2): file read/
            write/edit/listing, shell execution, git operations,
            oracle invocation; "destructive operations... require
            explicit human approval -- the same 'refuse with a named
            next action, no --force' posture the b3u launcher
            established." U1.3's own end_to_end.md text names ONLY
            "file read/write, workspace-scoped" as this step's slice --
            no shell, no git, no directory listing, no oracle
            invocation, no delete.
    EXISTS (B-5 + §2.12): "The developer can always answer 'what left
            this machine, when, to whom' from the ProviderRun trail."
            §3's `ProviderRun` entity (provider id, usage, tool trail,
            egress digest) is Track B (P4), not built. Consequence
            (matching U1.1's `policy`-field precedent exactly): THIS
            step's egress record is a plain FILE under the per-
            developer data home (§2.12: `~/.b3ubot`, overridable --
            the exact b3u_local.sh `$B3U_LOCAL_HOME` convention,
            renamed `$B3UBOT_HOME`), not a DB row -- named explicitly
            as the file-based PRECURSOR the done-when itself asks for
            ("B-5's first, file-based form"), not a placeholder hiding
            a gap.
    EXISTS (the "no --force" posture, referenced but not yet defined
            anywhere in b3ubot itself): b3u's launchers (e.g.
            b3u_local.sh's `-init` guard, C-29-C) refuse a destructive/
            ambiguous action with a NAMED next step rather than a
            blanket override flag. This step's confirmation-skip flag
            is named `--yes` (Q-4-C), never `--force` -- and workspace-
            scope enforcement is NEVER skippable by any flag, only the
            interactive PROMPT is.
    FOUND  (U1.4 is a SEPARATE, later deliverable -- do not build it
            here): end_to_end.md's own U1.4 text is "the live-gates
            script (the p1_gates.sh pattern): boot, mock arc, keyed
            arc, hygiene legs." This step proves the arc works (unit/
            functional tests against mock, honest auto-skip against
            claude-code); the standalone live-gates SCRIPT is U1.4's
            job, not duplicated here.
    MISSING: `app/tools.py` (workspace-scoped read/write); `app/
            egress.py` (the file-based trail); `app/cli.py` (`ask`);
            a toy-workspace test fixture; a NEW mock fixture scenario
            (the existing three don't match a realistic file-edit
            prompt); `$B3UBOT_HOME` resolution; the diff-computation +
            display logic; the confirmation gate.

## (2) What U1.3 delivers

  2.1 **app/tools.py -- the minimal tool surface**: `read_file(root,
      rel_path) -> str` and `write_file(root, rel_path, content) ->
      None`, BOTH workspace-scoped -- resolve the target path, refuse
      (raise `WorkspaceError`, fail loud) if it resolves OUTSIDE
      `root` (path-traversal guard: `../`, absolute paths, symlink
      escapes). No delete, no directory listing, no shell, no git --
      P3's `app/tools.py` (or wherever U3.2 lands) grows this file
      later; this step's version is deliberately narrow.
  2.2 **app/egress.py -- B-5's file-based trail**: `record_run(home,
      run_id, *, provider, workspace_root, sent_context, response,
      timestamp) -> Path` writes ONE JSON file per run under
      `$B3UBOT_HOME/runs/<run_id>.json` -- provider identity, the
      EXACT context bytes sent (not a hash-only summary -- the whole
      point is a developer can literally read what left the machine),
      the response received, usage/cost if the adapter reported any.
      `$B3UBOT_HOME` resolves from the `B3UBOT_HOME` env var, default
      `~/.b3ubot` (Q-4-A; the b3u_local.sh `$B3U_LOCAL_HOME`
      convention, renamed).
  2.3 **A new mock fixture scenario** (adapters/mock/fixtures.py,
      extending not rewriting U1.1's file): keyed on a short, test-
      controlled intent string as the FIRST context entry (Q-4-B:
      intent goes first in the context list specifically so the
      EXISTING exact-match routing rule stays simple and untouched --
      neither adapter cares about entry order for what it actually
      sends, only the mock's routing does). Scripts a plausible "here
      is the complete new file content" TEXT response.
  2.4 **app/cli.py -- `ask`**: `python -m app.cli ask <workspace-root>
      <rel-path> "<intent>" [--provider mock|claude-code] [--yes]
      [--dry-run]`. Flow: `tools.read_file` -> assemble a Request
      (intent-first context, per 2.3) -> the chosen adapter's `run()`
      -> collect the TEXT response as the proposed new content ->
      `difflib.unified_diff` against the original (display-only, no
      patch-format parsing needed since the whole file is replaced
      wholesale) -> print the diff -> GATE: `--yes` skips the
      interactive confirm, otherwise prompt y/N -> on approval,
      `tools.write_file`; `egress.record_run` fires regardless of the
      gate's outcome (a REFUSED apply still left workspace bytes for
      the provider to see -- B-5 tracks what left, not what got
      written). `--dry-run` never writes, still records the egress
      trail. Default provider = `mock` (Q-4-D: the safe, zero-cost
      default; `claude-code` is opt-in, matching the keyless-first
      precedent throughout this whole ecosystem).
  2.5 **Core logic kept testable, independent of the interactive
      prompt**: `run_ask(...)` is a plain function returning a result
      object; `main()` is the only place that calls `input()`. Tests
      call `run_ask` directly with `auto_approve=True/False`, never
      simulate a TTY.
  2.6 **A toy workspace fixture** (tests/fixtures/toy_workspace/): one
      small text file, copied into a `tmp_path` per test (never
      mutated in place) -- the literal "toy workspace" the done-when
      names.
  2.7 **Tests**: tests/test_tools.py (workspace-scope enforcement,
      positive + 3 escape attempts: `../`, absolute path, symlink);
      tests/test_egress.py (record_run's file shape, `$B3UBOT_HOME`
      override, no secret ever written -- there is none at this layer
      yet, but the trail's OWN hygiene matters going forward);
      tests/test_cli.py (the full arc via mock: propose -> diff ->
      `--yes` apply -> file changed -> egress recorded; the declined-
      confirmation path leaves the file untouched but STILL records
      egress; `--dry-run` never writes; a workspace-escape attempt via
      the CLI's own path argument refuses loudly; the claude-code leg,
      registered the same skipif way as step.3's contract suite).
  2.8 **Closure**: end_to_end.md §11 U1.3 -> done; README gains a one-
      paragraph `ask` example (light touch -- U1.4 owns the fuller
      gates/retro polish); memory. Retro pair via step_gdiff.

EXPLICITLY OUT OF SCOPE (each belongs to a later UoW):
  - Shell execution, git operations, directory listing, delete, oracle
    invocation -- P3's FULL tool surface (U3.2)                      -> P3
  - The orchestrator state machine (INTENT/SKELETON/RATIFIED/
    EXECUTING/...) -- this step's flow is a direct, unstated-machine
    precursor, named honestly (C-4-D)                                 -> P3
  - Real, gated, POLICY-driven egress enforcement (path allow/deny
    lists, secret redaction, consent UX) -- this step RECORDS what
    left, it does not yet DECIDE what may leave                       -> P7
  - A `ProviderRun` SQLite-backed entity -- this step's trail is the
    named file-based precursor                                        -> P4
  - `python -m app.cli`'s live-gates SCRIPT (boot/mock-arc/keyed-arc/
    hygiene legs) -- U1.4's own, separate deliverable                 -> U1.4
  - Multi-file / whole-repo context assembly (directory discovery,
    multiple files in one request) -- "toy workspace," one file       -> later
  - A packaged/installed console script (`pip install` entry point) --
    `python -m app.cli` is the interface this step ships              -> later

## (3) Verification gates

    G1  DONE-WHEN (end_to_end.md verbatim, two halves): the full `ask`
        arc (propose -> diff -> gate -> apply -> egress record)
        completes on the toy workspace fixture via mock, AND the
        claude-code leg is registered and SKIPS honestly in this
        key-less environment (the same live-or-honest-skip proof
        step.3 established, extended one layer up).
    G2  EVERY BYTE THAT LEFT IS RECORDED (B-5's own wording): the
        egress trail file for a run contains the EXACT context sent
        (not a summary, not a hash-only digest) -- verified by
        comparing the trail file's content against what
        `tools.read_file` actually returned for that run.
    G3  WORKSPACE-SCOPE ENFORCEMENT IS UNCONDITIONAL: `../` traversal,
        an absolute path outside the workspace root, and a symlink
        escaping the root are ALL refused by `app/tools.py` -- and
        NO CLI flag (including `--yes`) can bypass this (C-4-B).
    G4  GATE INTEGRITY: a declined confirmation (or the absence of
        `--yes` in a non-interactive test context) leaves the target
        file byte-identical to before the run; `--dry-run` never
        writes regardless of confirmation; BOTH paths still record an
        egress entry (G2's property holds even when nothing was
        applied).
    G5  NO PROVIDER NAME IN THE PAL CONTRACT LAYER (the standing
        precedent, re-run CORRECTLY this time -- see step.4.diff.txt
        finding #1): `grep -rn -e mock -e claude -e openai app/pal/`
        stays empty. This does NOT extend to app/cli.py, which
        legitimately names providers to implement `--provider`
        selection -- exactly what any adapter-selection factory must
        do (§2.11's rule protects the CONTRACT layer, not every
        caller). app/tools.py and app/egress.py stay provider-
        agnostic in practice (grep-confirmed) though nothing requires
        them to be.
    G6  NO SECRET IN THE EGRESS TRAIL: grep the trail-writing code and
        a sample written trail file for `ANTHROPIC_API_KEY` / any
        credential-shaped string -- empty (the trail records CONTENT
        exchanged with the provider, never the credential used to
        reach it).
    G7  NO REGRESSION: all of step.1/step.2/step.3's existing tests
        stay green, byte-identical in behavior; `make smoke` still
        passes unmodified.
    G8  Hygiene: b3ubot porcelain clean after each commit; retro via
        step_gdiff.

## (4) LOCKs

    C-4-A  b3ubot porcelain clean after each commit.
    C-4-B  WORKSPACE-SCOPE IS UNCONDITIONAL: no flag, mode, or
           provider choice may cause `app/tools.py` to read or write
           outside the given workspace root -- ever (G3).
    C-4-C  EGRESS RECORDING IS UNCONDITIONAL: `egress.record_run`
           fires on every `ask` invocation that reaches the adapter
           call, regardless of the confirmation gate's outcome or
           `--dry-run` (G2/G4) -- B-5 tracks what left the machine,
           which happens at the PAL call, not at the write.
    C-4-D  HONEST PRECURSOR NAMING: this step's direct read-assemble-
           call-gate-write flow is explicitly named as a SIMPLIFIED
           stand-in for the real EXECUTING-state machinery P3 builds
           -- never described as "the orchestrator" in code or docs.
    C-4-E  NO --force: the confirmation-skip flag is `--yes`, not
           `--force`; it skips ONLY the interactive prompt, never any
           safety check (C-4-B/C-4-C both stay unconditional under
           `--yes`).
    C-4-F  standing locks inherited: B-1 (moot, no CCS surface here),
           B-3 (no disclosure/push), B-4 (this step calls the SAME
           adapters step.2/step.3 already hardened, no new credential
           surface introduced), B-5 (this step's whole purpose is
           B-5's first concrete instance), B-6 (T0/T1 tests only, no
           AI-review tier exists).

## (5) Open questions

    Q-4-A  Data home resolution: `$BUBOT_HOME` env var, default
           `~/.b3ubot` (LEAN -- direct rename of the proven
           `$B3U_LOCAL_HOME` convention from the b3u sibling, zero new
           design). LEAN, spelled precisely: `B3UBOT_HOME`.
    Q-4-B  Context entry order for the mock's routing: intent FIRST,
           file-content-bearing context SECOND (LEAN -- keeps U1.1's
           existing exact-match routing rule untouched and simple to
           extend; ClaudeCodeAdapter flattens everything into one
           string regardless, so real-world behavior is unaffected by
           this choice) vs system/file-context first, matching a more
           conventional LLM-prompt shape (would need fixtures.py's
           routing rule to change, real rework of already-closed
           step.2 infrastructure for a cosmetic gain). LEAN:
           intent-first.
    Q-4-C  Confirmation-skip flag name: `--yes` (LEAN -- standard Unix
           convention, e.g. apt/yum's `-y`; explicitly NOT `--force`
           per §2.7's own "no --force posture" wording) vs
           `--force` (rejected outright by the design text itself).
           LEAN: `--yes`.
    Q-4-D  Default provider: `mock` (LEAN -- zero cost, zero network,
           matches the keyless-first default established at every
           prior step in both this repo and the b3u sibling;
           `claude-code` requires an explicit `--provider claude-code`
           opt-in) vs defaulting to "best available" (auto-detect and
           silently prefer claude-code if configured -- REJECTED: a
           silent default that sometimes spends real money and
           sometimes doesn't, depending on unstated environment state,
           is exactly the kind of surprise this project's discipline
           avoids). LEAN: mock default, explicit opt-in for real spend.
    Q-4-E  Diff computation: `difflib.unified_diff` over the WHOLE
           file (old vs proposed-new), stdlib only (LEAN -- no patch-
           format parsing needed since the apply is a wholesale
           overwrite, not a partial patch; zero new dependency,
           matches every prior minimalism decision) vs asking the
           provider to emit an actual unified-diff-formatted response
           and parsing/applying THAT (more "realistic" for a future
           multi-hunk editor, but needs patch-application logic this
           toy-workspace, one-file step has no real use for yet).
           LEAN: whole-file diff, wholesale overwrite on apply.

## (6) Acceptance

    - [x] app/tools.py: read_file/write_file, workspace-scoped,
          WorkspaceError on any escape attempt (2.1, C-4-B, G3).
    - [x] app/egress.py: record_run, $B3UBOT_HOME-resolved, full
          context+response content written, no secret (2.2, C-4-C,
          G2, G6).
    - [x] adapters/mock/fixtures.py: one new scenario, intent-first
          routing (2.3, Q-4-B).
    - [x] app/cli.py: `ask` -- propose/diff/gate/apply/egress, --yes/
          --dry-run/--provider flags, run_ask() testable independent
          of input() (2.4, 2.5, C-4-D/E, Q-4-C/D/E).
    - [x] tests/fixtures/toy_workspace/ + test_tools.py (7) +
          test_egress.py (5) + test_cli.py (10), mock leg fully green,
          claude-code leg registered + honestly skipped in this
          environment (2.6, 2.7, G1, G4).
    - [x] G5 CORRECTED mid-build (grep app/pal/, not app/ -- see
          step.4.diff.txt finding #1) and re-verified clean; G6
          grep-verified.
    - [x] G7 full regression replay clean (46 collected, 38 passed/8
          skipped); ALSO verified with a real manual `python -m
          app.cli ask` invocation (scratch workspace + explicit
          $B3UBOT_HOME), not pytest alone -- real ~/.b3ubot on this
          machine confirmed untouched.
    - [x] Closure: end_to_end §11 U1.3 -> done + README `ask` example +
          memory; retro pair via step_gdiff (2.8, G8).

## (7) Hash backfill

    SKELETON commit (this file + ledger row U1.3 -> active):    16f5355
    BUILD commit (tools + egress + cli + tests, all gates green): 659cdb6
    RETRO commit (retro pair + ledger done):                     (HEAD)

End of skeleton.
