step.8.diff.txt -- narrative retro for step.8 (U1.6: wire --provider
human into ask, reconcile propose_ask())

Executed: 2026-07-20, separate session from the skeleton (9216667,
same day) per the operator's explicit sequencing directive ("create
the step text file FIRST, operator reviews it, THEN explicitly
requests execution") -- Q-8-A and Q-8-B confirmed by the operator
(Option A; leaning (ii)) before this execution began, not
re-litigated. Companion artifact: step.8.gdiff.txt (automated,
scripts/step_gdiff).

## Plan vs actual

```
+---+----------------------------------+------------------------------------------+
| # | Planned (§)                      | Actual                                   |
+---+----------------------------------+------------------------------------------+
| 1 | §2.2: propose_ask() reconciled   | Held as spec'd, with one refinement not  |
|   | to accept TEXT or content-       | called out at drafting: an empty-text    |
|   | bearing terminal DONE            | terminal DONE gets its OWN distinct      |
|   |                                  | RuntimeError message ("terminal DONE     |
|   |                                  | carried no text (empty paste-back?)"),   |
|   |                                  | not the pre-existing generic "no TEXT    |
|   |                                  | response produced" message -- G1's own   |
|   |                                  | DONE-WHEN text asked for this            |
|   |                                  | ("DOES raise a clear, distinct error");  |
|   |                                  | drafting hadn't spelled out the exact    |
|   |                                  | message shape, execution filled it in.   |
| 2 | §2.3: tests/test_cli.py gets a   | Found live, while writing the first new  |
|   | new case exercising              | test: mock/claude-code tests never       |
|   | provider="human" with an         | inject a pre-built adapter into          |
|   | injected HumanRelayAdapter,      | propose_ask() -- both just pass a        |
|   | "however propose_ask()/          | provider STRING and let _make_adapter()  |
|   | _make_adapter() allow test       | build the (deterministic/network-        |
|   | injection"                       | skipping) instance internally, because   |
|   |                                  | neither needed fake I/O. HumanRelayAdapter |
|   |                                  | genuinely does (C-8-B) and no such seam  |
|   |                                  | existed. Added one: propose_ask() gained |
|   |                                  | an `adapter: Adapter | None = None`      |
|   |                                  | keyword parameter -- when given, used    |
|   |                                  | AS-IS instead of `_make_adapter          |
|   |                                  | (provider)` building one. Existing call  |
|   |                                  | sites are unaffected (default None ->    |
|   |                                  | identical behavior to before this step). |
|   |                                  | This is genuinely new surface on         |
|   |                                  | propose_ask(), not silent scope creep    |
|   |                                  | (C-8-C): it is the injection seam C-8-B  |
|   |                                  | requires and 2.3 explicitly anticipated  |
|   |                                  | might be needed.                         |
| 3 | §2.3: "if this step's           | No further widening needed.              |
|   | propose_ask() change reveals    | test_pal_contract.py's test_text_response |
|   | the step.7 contract-suite       | (step.7's own extension) already asserts |
|   | extension was itself            | exactly the predicate propose_ask() now  |
|   | incomplete, report it"          | implements -- distinct TEXT first, else  |
|   |                                  | a content-bearing terminal DONE. Zero    |
|   |                                  | edits to test_pal_contract.py this step  |
|   |                                  | (confirmed via git diff --stat).         |
+---+----------------------------------+------------------------------------------+
```

One process deviation worth recording plainly (not a design finding, a
commit-shape one): the operator's commit-sequence instructions for
this step directed README.md/design.md/end_to_end.md into the BUILD
commit alongside the code and tests. steps/step.4..6's own established
precedent (re-checked live against git history before writing this
file) actually put doc changes in the RETRO commit, not BUILD --
mechanically necessary for end_to_end.md §11's Commit column
specifically, since a commit cannot contain its own hash. This step
followed the operator's explicit instruction for README.md/design.md/
end_to_end.md §3 CONTENT (all landed in BUILD, 8c58f8d), but left the
§11 ledger row's Commit cell blank in BUILD and fills it in here, in
this RETRO commit, once 8c58f8d's hash is known -- the one cell where
the chicken-and-egg constraint step.7.diff.txt already documented is
unavoidable regardless of which commit the surrounding doc prose lives
in.

Both §5 open questions resolved exactly as confirmed going in: Q-8-A
(Option A, propose_ask() generalized, HumanRelayAdapter untouched),
Q-8-B (leaning (ii), refuse loudly on a detected non-TTY stdin, no new
flag).

## Gate evidence

```
+------+---------------------------------------------------------------------+
| G1   | Live manual invocation (not just pytest): piped empty stdin against |
|      | --provider human refused immediately with the named-next-action     |
|      | message and rc=1 -- no hang, confirmed by running it directly, not  |
|      | inferred from the test. The two propose_ask() halves of G1          |
|      | (well-formed paste-back succeeds; empty paste-back raises a         |
|      | DISTINCT, non-generic error) are each their own pytest case         |
|      | (test_propose_ask_provider_human_computes_diff_from_done_text,      |
|      | test_propose_ask_provider_human_empty_paste_back_raises_distinct_   |
|      | error) -- both pass.                                                |
| G2   | grep -rn -e '"human"' -e "'human'" --include=*.py . outside          |
|      | app/cli.py, adapters/, tests/ -> empty. The pre-existing exception  |
|      | (mock/claude-code provider-name literals already live in           |
|      | app/cli.py) is the only place "human" joins as a literal; no other  |
|      | file gained one.                                                    |
| G3   | Full suite before this step's edits: 52 passed/11 skipped           |
|      | (confirmed live, matching step.7's own recorded baseline exactly).  |
|      | After: 55 passed/11 skipped -- +3, all new (the two propose_ask()   |
|      | human cases + the Q-8-B non-TTY refusal case), zero regressions in  |
|      | the existing 52. make smoke: SMOKE PASS, unchanged output.          |
| G4   | The decision is recorded in four places, not implicit: this file,  |
|      | steps/step.8.txt (this backfill), design.md §5 d13's ADDENDUM       |
|      | (dated 2026-07-20, names both Q-8-A and Q-8-B's resolutions and     |
|      | reasons), and app/cli.py's own module docstring + inline comments   |
|      | at the _make_adapter "human" branch, the propose_ask() acceptance   |
|      | logic, and the main() TTY guard.                                    |
| G5   | b3ubot porcelain clean after the BUILD commit (confirmed via git    |
|      | status before this retro commit); retro pair via step_gdiff.        |
+------+---------------------------------------------------------------------+
```

## Findings for later steps

1. **"Extend, don't rewrite" sometimes still needs one new seam, and
   that's not the same thing as scope creep.** C-8-C's letter says
   this step touches propose_ask()'s acceptance rule and the CLI's
   provider list ONLY; adding an `adapter=` parameter is, strictly,
   new surface on propose_ask() beyond its acceptance-rule logic. It
   was added anyway because C-8-B (injected I/O only in tests) is a
   standing lock that cannot be honored for HumanRelayAdapter through
   provider-name-string dispatch alone -- unlike mock/claude-code,
   which never needed fake I/O because their non-determinism (or lack
   of it) lives elsewhere (fixtures, network skip). The distinction
   that keeps this from being creep: the parameter is additive and
   default-None, so every existing call site (run_ask(), main(), the
   pre-step.8 test suite) is byte-for-byte unaffected. Worth
   remembering for any FUTURE adapter whose offline-testability story
   needs constructor-level fakes: expect propose_ask()'s `adapter=`
   seam to be the reusable answer, not a new one-off per adapter.
2. **A "the CLI's acceptance rule now matches the contract suite's"
   claim is only as strong as an explicit side-by-side re-read.**
   Before writing propose_ask()'s new branch, test_pal_contract.py's
   test_text_response (step.7's own extension) was re-read line by
   line and the predicate mirrored deliberately: distinct TEXT first,
   else a content-bearing terminal DONE. It would have been easy to
   independently invent a "looks equivalent" rule instead (e.g.
   checking `responses[-1].kind == DONE` without the truthy-text
   guard) that quietly diverged on an edge case -- which is exactly
   what G1's empty-paste-back requirement was there to catch, and
   did: the naive rule would have let an empty paste-back through as
   an empty proposed_content instead of raising.
3. **Where a stdin/TTY guard lives is a real design choice, not
   incidental.** Putting it in main() rather than propose_ask() or
   _make_adapter() wasn't just "because the task said so" -- it's the
   same boundary step.4's own design intent already drew ("main() is
   the ONLY place that [calls input()], so tests never need to
   simulate a TTY"). Q-8-B's guard extends that boundary rather than
   crossing it: the thing being guarded (a REAL blocking input() call)
   only exists on main()'s real path, so the guard belongs exactly
   where the hazard is introduced, not one layer down where it would
   leak into every test that exercises propose_ask() directly.

## Numbers

```
+-----------------------------------------+----------------------+
| files changed (BUILD, 8c58f8d)          | 6 (README.md,        |
|                                          | adapters/human/      |
|                                          | adapter.py, app/     |
|                                          | cli.py, design.md,   |
|                                          | end_to_end.md,       |
|                                          | tests/test_cli.py)   |
| new propose_ask() parameter             | 1 (adapter=, default |
|                                          | None, additive)      |
| new test_cli.py cases                   | 3 (human diff-from-  |
|                                          | DONE, empty-paste-   |
|                                          | back distinct error, |
|                                          | non-TTY refusal)      |
| files deliberately NOT touched          | 1 (test_pal_contract |
|                                          | .py -- already       |
|                                          | correct, step.7)     |
| suite before -> after                   | 52p/11s -> 55p/11s   |
| regressions                             | 0                    |
+-----------------------------------------+----------------------+
```

U1.6 done -- P1.M1's provider abstraction proof is now demonstrably
end-to-end for all three registered adapters (mock, claude-code,
human) through the same `ask` CLI surface. Sibling to U1.4 (P1 gates +
retro, still todo) -- neither blocks the other. Ledger row's Commit
cell (end_to_end.md §11) filled in with 8c58f8d in this same RETRO
commit, for the mechanical reason recorded above.

End of retro.
