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.
