step.4.diff.txt -- narrative retro for step.4 (U1.3: one-round-trip CLI) Executed: 2026-07-18, same day as the skeleton. Companion artifact: step.4.gdiff.txt (automated, scripts/step_gdiff). This is the first piece of b3ubot a human can actually run -- and the first step whose own gate wording turned out to be wrong before a single line of adapter/tool code was even tested. ## Plan vs actual ``` +---+----------------------------------+------------------------------------------+ | # | Planned (§) | Actual | +---+----------------------------------+------------------------------------------+ | 1 | §3 G5: "grep -rn -e mock -e | Caught WHILE WRITING app/cli.py: the | | | claude -e openai app/ stays | file legitimately needs literal "mock"/ | | | empty" | "claude-code" strings to implement | | | | --provider selection -- exactly what any | | | | adapter-selection factory has to do. | | | | The skeleton's own gate, as WRITTEN, | | | | could never pass once app/cli.py | | | | existed. Root cause: design.md §2.11's | | | | actual rule is "PAL ⟂ workflow" -- | | | | protects app/pal/ (the CONTRACT layer) | | | | specifically, not the whole app/ tree. | | | | Fixed the SKELETON's own G5 text before | | | | it could mislead a future re-check, | | | | re-scoped the grep to app/pal/ (matching | | | | step.2/step.3's ACTUAL precedent, which | | | | I had misquoted when drafting this | | | | step). app/tools.py and app/egress.py | | | | stay provider-agnostic anyway (grep- | | | | confirmed) -- nothing REQUIRES that of | | | | them, they just happen to have no reason | | | | to name a provider. | +---+----------------------------------+------------------------------------------+ ``` Every §5 open question resolved exactly as LEANed -- no other deviations. Q-4-B's intent-first ordering paid off immediately: adding the "add a greeting line" mock scenario needed zero changes to fixtures.py's routing rule, exactly as designed. ## Gate evidence ``` +------+---------------------------------------------------------------------+ | G1 | 46 collected, 38 passed / 8 skipped. The 8 skips: test_pal_contract | | | .py's 7 (unchanged from step.3) + test_cli.py's 1 new claude-code | | | test, all with the identical honest reason string. Mock arc fully | | | green. | | G2 | test_egress_trail_contains_exact_context + test_record_run_writes_ | | | full_content both assert the WRITTEN file's sent_context equals | | | the exact context object passed in -- not a hash, not a summary. | | | Also verified by hand: a real manual `ask --dry-run` run's egress | | | JSON was read back and inspected -- the full system-role prompt | | | (file content + instructions) is right there, byte for byte. | | G3 | 7 escape-vector tests in test_tools.py (../, absolute path, | | | symlink -- both read and write directions for the first two) plus | | | test_apply_ask_respects_workspace_scope and | | | test_main_workspace_escape_refused_cleanly at the CLI layer. All | | | refuse; --dry-run does not weaken the refusal (checked explicitly | | | in the CLI-level test). | | G4 | test_run_ask_declined_leaves_file_untouched and | | | test_run_ask_dry_run_never_writes_even_if_approved BOTH assert the | | | file is byte-identical to before AND that egress_path.is_file() is | | | still true -- the "record regardless of outcome" property proven | | | for both non-apply paths, not just one. | | G5 | grep -rn -e mock -e claude -e openai app/pal/ -> empty (corrected | | | target, see finding #1). | | G6 | grep for ANTHROPIC_API_KEY across app/egress.py, app/cli.py, | | | app/tools.py, and the new test files -> only test assertions | | | CHECKING for its absence, never a value. | | G7 | rm -rf .venv work && make venv/smoke/test: 38 passed, 8 skipped, | | | zero regression on any of step.1/2/3's prior tests. PLUS a real, | | | non-pytest manual invocation (`python -m app.cli ask` against a | | | scratch workspace with an explicit $B3UBOT_HOME) -- confirmed the | | | diff output, the dry-run no-write guarantee, and the written | | | egress file's actual content, by hand, outside the test harness. | | | `ls ~/.b3ubot` on the real machine still fails -- never touched. | +------+---------------------------------------------------------------------+ ``` ## Findings for later steps 1. **A gate's grep TARGET is itself a design claim, and needs the same scrutiny as the code it checks.** step.4's skeleton copied step.2/step.3's "no provider name" gate language but mis-transcribed the target from `app/pal/` to `app/` -- a one-word slip that would have made the gate permanently unsatisfiable the moment app/cli.py (a LEGITIMATE provider-selector) existed. Caught only because the code was actually written and the grep actually run, not because the skeleton was re-read carefully enough. Lesson: when copying a gate pattern from a prior step, re-derive WHY it was scoped that way (here: §2.11 protects the contract layer, not every caller) rather than pattern-matching the prior step's exact wording. 2. **Separating propose/apply (rather than one function with a confirm-callback) made the CLI's error-handling boundary obvious almost for free.** Once `propose_ask`/`apply_ask` were pure, input()-free functions, it was natural to put ALL presentation- layer concerns (the diff print, the confirm prompt, the clean error messages) in `main()` alone -- there was no temptation to sneak a `print()` or an `input()` into the "library" functions, because the split already existed. Worth keeping as the template for any future b3ubot CLI command. 3. **The egress trail's "record regardless of outcome" property (C-4-C) is more valuable than it looks at first.** A declined or dry-run request STILL sent real workspace content to the adapter (mock or real) -- B-5's question ("what left this machine") is answered at the PAL CALL, not at the file write. Getting this backwards (recording only on apply) would have created a real, silent gap: a user could decline every proposed change and still have leaked file content on every declined attempt, invisibly. 4. **Manual, non-pytest verification of a CLI is worth doing even when the automated suite is thorough.** The pytest suite exercises `propose_ask`/`run_ask`/`main()` as Python calls; it does NOT prove the actual `python -m app.cli ask ...` invocation (module resolution, argparse wiring, real stdout formatting) works as a human would experience it. The one manual run this step did was cheap and caught nothing wrong here, but it's the kind of check that WOULD catch an import-path or argparse-wiring mistake pytest itself can't see, since pytest calls `main()` in-process with a list of strings, never through the actual `python -m` entry point. ## Numbers ``` +--------------------------------------+----------------+ | new modules | 3 (tools, | | | egress, cli) | | new tests | 22 (7+5+10) | | new mock fixture scenarios | 1 | | workspace-escape vectors covered | 3 (../, abs, | | | symlink) | | skeleton corrections found mid-build | 1 (G5's grep | | | target) | | BUILD commit 659cdb6 | 9 files, +595 | +--------------------------------------+----------------+ ``` U1.3 done -> unblocks U1.4 (P1 gates + retro, which closes the whole P1 phase -- the live-gates SCRIPT, explicitly NOT built this step, is U1.4's own job). Ledger updated in this RETRO commit per the §0 execution contract. End of retro.