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.