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/.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 "" [--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.