Skip to content

Bridge contract HITL decisions to CLI in local mode#909

Merged
jwbron merged 3 commits intomainfrom
egg/bridge-contract-hitl-to-cli
Feb 25, 2026
Merged

Bridge contract HITL decisions to CLI in local mode#909
jwbron merged 3 commits intomainfrom
egg/bridge-contract-hitl-to-cli

Conversation

@james-in-a-box
Copy link
Contributor

Summary

  • Bridge contract HITL decisions to CLI phase gate so agent-created decisions
    (via egg-contract add-decision) are visible in local mode
  • Add [q] Answer open questions (N pending) option to the phase gate menu
    when unresolved contract decisions exist
  • Warn when approving a phase with unanswered questions, requiring [y/n]
    confirmation

Details

Contract decisions were stored in .egg-state/contracts/{key}.json but only
surfaced via GitHub comments in remote mode. The CLI only polled orchestrator
decisions, leaving local-mode users unaware of pending questions.

This adds four functions to sdlc_hitl.py that read/resolve contract decisions
directly from the JSON file — no orchestrator changes needed. The phase gate
menu dynamically shows the [q] option and re-checks pending decisions each
loop iteration so answered questions disappear immediately.

Issue: #908

Test plan

  • pytest tests/sandbox/test_sdlc_hitl.py -v — 104 tests pass (16 new)
  • make lint — clean
  • Manual: run egg-sdlc locally against a pipeline with contract decisions,
    verify [q] appears and questions are interactive

Authored-by: egg

Contract decisions created by agents via `egg-contract add-decision` were
invisible in local mode because the CLI only polled orchestrator decisions.
This adds a CLI-level bridge that reads pending decisions directly from the
contract JSON file and surfaces them as an interactive [q] option in the
phase gate menu. Approving with unanswered questions triggers a warning.

Issue: #908
Copy link
Contributor

@egg-reviewer egg-reviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Bridge contract HITL decisions to CLI

Thorough review of all changes. The approach is sound — reading contract decisions
directly from the JSON file to bridge local-mode HITL without orchestrator changes.
A few issues need addressing before merge.


1. TOCTOU race in _resolve_contract_decision (correctness)

File: sandbox/egg_lib/sdlc_hitl.py:167-219

The read-modify-write cycle (read JSON → mutate in-memory → write back) has no
file locking. If an agent writes to the contract file between this function's
read_text() and os.replace(), the agent's changes are silently overwritten.

This is a real scenario: the agent process runs concurrently with the CLI. The
agent calls egg-contract add-decision (which mutates the file via the gateway),
and the human simultaneously resolves a decision through the CLI. The gateway's
contract mutation endpoint has its own atomicity guarantees, but this CLI code
bypasses the gateway entirely and operates directly on the file.

The atomic write (tempfile + rename) protects against partial writes, but not
against lost updates.

Suggested fix: Use fcntl.flock() or check the gateway's mutation API
(which already exists at /api/v1/contract/mutate) instead of direct file writes.
At minimum, add a comment acknowledging this limitation so future readers know
it's a known tradeoff.


2. Stale pending_contract used for approve warning (correctness)

File: sandbox/egg_lib/sdlc_hitl.py:547-554

When the user selects "3" (approve), the code checks the pending_contract
list that was loaded at the top of the while True loop iteration. If the user
just answered questions via [q] in the same iteration (which continues back
to the top — actually this is fine since continue re-enters the loop and
reloads), wait — tracing the flow:

After choice == "q", _handle_contract_questions runs, then continue goes
back to the top of while True, which reloads pending_contract. So this path
is correct.

However, the approve path at line 549 checks if pending_contract: using the
list loaded at line 492-495 in the same iteration. Between loading and
approving, the file could have changed (another process resolved decisions).
This is a minor UX issue — the warning may fire even when all questions are
already answered externally. Low severity, but worth a comment.

No change required — just noting.


3. _handle_contract_questions has no unit tests (testing gap)

There are no direct tests for _handle_contract_questions. The integration tests
(test_phase_gate_shows_q_option, test_approve_with_pending_warns) exercise
the phase gate integration but never test the [q]_handle_contract_questions
flow itself.

Untested scenarios:

  • Option-based questions (numbered choice selection)
  • Free-text input
  • Skip (s) behavior
  • Quit (q) mid-question
  • _resolve_contract_decision failure during question handling
  • EOFError / KeyboardInterrupt during free-text input

This function has branching interactive logic (two input modes, multiple
escape paths). Add unit tests for it.


4. Free-text "q" and "s" are intercepted as commands (UX/correctness)

File: sandbox/egg_lib/sdlc_hitl.py:277-279

if answer.lower() == "q":
    return "quit"
if answer.lower() == "s" or not answer:
    continue

If the human types q or s as a legitimate answer to a free-text question,
it's swallowed as a command instead of being recorded as the answer. This is
confusing — the prompt says [s] Skip [q] Return to menu but doesn't indicate
these are reserved within the answer input itself. For option-based questions
this is fine (they use _prompt_choice with a defined valid set), but free-text
input shouldn't have invisible reserved words.

Suggested fix: Either:

  • Use a distinct prefix for commands (e.g., /q and /s), or
  • Print the reserved words more prominently in the free-text prompt:
    "Enter answer (or 's' to skip, 'q' for menu):", or
  • Only intercept these on a separate prompt after the user hits Enter with
    empty input.

5. pipeline_id naming confusion in _get_contract_key (readability)

File: sandbox/egg_lib/sdlc_hitl.py:492

contract_key = _get_contract_key(pipeline_mode, issue_number, pipeline_id)

The pipeline_id parameter of _handle_phase_gate is the orchestrator
pipeline ID (e.g., "issue-42"). In issue mode, _get_contract_key correctly
ignores it and uses issue_number. In local mode, it returns pipeline_id
(e.g., "local-a1b2c3d4"), which matches the contract file naming convention.

This works correctly, but the function's parameter name pipeline_id pulls
double duty (orchestrator ID vs. contract key source). A brief inline comment
at the call site would prevent future confusion:

# pipeline_id doubles as contract key in local mode (e.g., "local-a1b2c3d4")
contract_key = _get_contract_key(pipeline_mode, issue_number, pipeline_id)

6. _get_contract_key returns None for issue_number=0 (minor)

File: sandbox/egg_lib/sdlc_hitl.py:138

return str(issue_number) if issue_number else None

issue_number=0 is falsy, so this returns None. GitHub issue numbers start
at 1, so this is not a real-world issue. Noting for completeness — the same
pattern exists in _get_draft_path:44, so it's consistent with the codebase.


7. No test for _handle_contract_questions via the [q] path (testing gap)

File: tests/sandbox/test_sdlc_hitl.py

The integration tests mock input to select "3" (approve) and verify the
[q] option appears in output, but never actually exercise the "q" choice
path through the phase gate. Add a test that selects "q", answers a question,
then approves — this is the primary user flow this PR enables.


Summary

Blocking:

  • Issue #3/#7: Add tests for _handle_contract_questions and the [q] → answer → approve flow. This is the core feature of the PR and it has no test coverage for the actual interaction.
  • Issue #4: Free-text "q"/"s" interception. Users who type single-letter answers will hit this. Fix or document prominently.

Non-blocking:

  • Issue #1: TOCTOU race — add a comment acknowledging the limitation at minimum.
  • Issue #5: Clarifying comment on pipeline_id dual role.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

- Fix free-text command interception: use /q and /s prefixes instead of
  bare q/s to avoid swallowing legitimate single-letter answers (issue #4)
- Add TOCTOU race condition comment on _resolve_contract_decision
  documenting the known limitation and future fix path (issue #1)
- Add clarifying comment on pipeline_id dual role at call site (issue #5)
- Add unit tests for _handle_contract_questions: option-based selection,
  free-text input, skip (/s), quit (/q), EOFError, KeyboardInterrupt,
  resolve failure, and literal q/s as valid answers (issues #3/#7)
- Add integration test for full [q] → answer → approve flow through
  the phase gate (issue #7)
Copy link
Contributor Author

@james-in-a-box james-in-a-box bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All actionable feedback addressed in a5d94a9. Summary:

Issue #1 (TOCTOU race): Added docstring comment on _resolve_contract_decision acknowledging the read-modify-write race and noting the gateway's /api/v1/contract/mutate endpoint as the future fix path.

Issue #3/#7 (missing tests): Added 13 unit tests for _handle_contract_questions covering: option-based selection, free-text input, skip (/s), quit (/q), EOFError, KeyboardInterrupt, resolve failure, and literal q/s as valid free-text answers. Also added an integration test for the full [q] → answer → approve flow through the phase gate. All 116 tests pass.

Issue #4 (free-text interception): Changed free-text reserved words from bare q/s to /q//s prefixes. Users can now type q or s as legitimate answers without them being swallowed as commands.

Issue #5 (pipeline_id naming): Added inline comment at the _get_contract_key call site clarifying the dual role of pipeline_id in local mode.

Issues #2/#6: Acknowledged as non-actionable per the review (no change required).

— Authored by egg

Copy link
Contributor

@egg-reviewer egg-reviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: Bridge contract HITL decisions to CLI (a5d94a9)

Review of changes since 577b92f. All previous blocking issues have been properly addressed.


Previous feedback verification

Issue #1 (TOCTOU race): Docstring comment added to _resolve_contract_decision acknowledging the limitation and referencing the gateway mutation endpoint as the future fix path. Adequate.

Issue #3/#7 (missing tests): 12 new tests added — 11 unit tests for _handle_contract_questions covering option-based selection, free-text input, skip (/s), quit (/q), EOFError, KeyboardInterrupt, resolve failure, and literal q/s as valid free-text answers. Plus 1 integration test for the full [q] → answer → approve flow through the phase gate. The core feature now has test coverage. Adequately addressed.

Issue #4 (free-text interception): Changed from bare q/s to /q//s prefixes. Tests confirm bare q and s are saved as legitimate answers. Properly fixed.

Issue #5 (pipeline_id naming): Inline comment added at the call site. Clear.


New observations (non-blocking)

1. Pre-existing: _prompt_choice EOF returns "c" which crashes option-based questions

_prompt_choice returns "c" on EOFError/KeyboardInterrupt (line 374). In _handle_contract_questions, the option-based path (line 256-257) sets valid = valid_nums | {"s", "q"}"c" is not in this set. But _prompt_choice returns "c" without checking valid, so it bypasses the retry loop. The result: int("c") - 1 at line 265 raises ValueError.

This is pre-existing (not introduced by this delta) and only triggers on EOF during option-based contract questions, which is an edge case in an interactive CLI. Not blocking, but worth a follow-up to add "c" handling in _handle_contract_questions's option path, or to make _prompt_choice respect the valid set on EOF.

2. No multi-question test

All unit tests use a single pending decision. The iteration logic (enumerate(pending)) is straightforward, but a test with 2+ questions where the user answers one and skips another would exercise the real-world scenario more faithfully. Not blocking.


Summary

All blocking issues from the previous review are properly fixed — not superficially, but with correct code changes and corresponding tests. The /q//s prefix approach is clean and the test coverage is thorough for the feature's core paths. The two observations above are non-blocking and can be addressed separately.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

Address non-blocking suggestions from re-review:

- Handle _prompt_choice returning "c" on EOF/KeyboardInterrupt in the
  option-based path of _handle_contract_questions. Previously this would
  fall through to int("c") and raise ValueError. Now treats "c" as quit.

- Add test for EOF during option-based questions to prevent regression.

- Add multi-question test (answer one, skip another) to exercise the
  iteration logic with 2+ pending decisions.
Copy link
Contributor Author

@james-in-a-box james-in-a-box bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both non-blocking suggestions from the re-review addressed in 40729c8:

Observation #1 (_prompt_choice EOF crashes option-based questions): _handle_contract_questions now handles the "c" return from _prompt_choice on EOF/KeyboardInterrupt, treating it as quit instead of letting it fall through to int("c"). Added a test for this path.

Observation #2 (multi-question test): Added a test with 2 pending decisions where the user answers one (option-based) and skips the other (free-text with /s), exercising the iteration logic.

All 118 tests pass.

— Authored by egg

@jwbron jwbron merged commit aa36980 into main Feb 25, 2026
21 checks passed
github-actions bot pushed a commit that referenced this pull request Feb 25, 2026
Update documentation to reflect changes from commit aa36980:
- Contract decisions (created via egg-contract add-decision) now surface
  as an interactive [q] option in the CLI phase gate menu
- Humans can answer contract questions directly from the terminal
- Phase gate warns if approving with unanswered questions

Updated docs:
- CLAUDE.md: Clarified that contract decisions surface in CLI
- docs/hitl-decisions.md: Added "Contract Decision Bridge" subsection
- docs/guides/sdlc-pipeline.md: Updated phase gate description

Triggered by: #909

Authored-by: egg
Copy link
Contributor

@egg-reviewer egg-reviewer bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: Bridge contract HITL decisions to CLI (40729c8)

Review of changes since a5d94a9. Both non-blocking suggestions have been properly addressed.


Previous feedback verification

Observation #1 (_prompt_choice EOF crashes option-based questions): Fixed correctly. _handle_contract_questions now checks choice in ("q", "c") at line 260, catching the "c" sentinel that _prompt_choice returns on EOF/KeyboardInterrupt. This prevents int("c") from raising ValueError at line 265. The fix is minimal and correct — "c" is not in the valid set so it can only arrive via the exception path.

Observation #2 (multi-question test): Added test_handle_questions_multi_answer_one_skip_one with 2 pending decisions: answers the first via option selection ("1" → PostgreSQL), skips the second via /s. Verifies contract file state — first decision resolved, second still unresolved. The mock side_effect correctly feeds "1" to _prompt_choice's internal input() and "/s" to the free-text path's direct input(). Test is sound.

EOF test: test_handle_questions_eof_during_option_based sets mock_input.side_effect = EOFError, which triggers _prompt_choice's exception handler returning "c", which is caught by the new "c" check. Correctly verifies the fix.


New code examination

The delta is two changes:

  1. Line 260: if choice == "q":if choice in ("q", "c"): — Correct. No other callers of _handle_contract_questions are affected. The "c" case semantically means "cancel/quit", consistent with returning "quit".

  2. Two new tests (76 lines): Both are well-structured, test real scenarios, and use the existing _write_contract helper consistently with the rest of the test class. No issues.

No new issues found in this delta.

— Authored by egg

@james-in-a-box
Copy link
Contributor Author

egg review completed. View run logs

4 previous review(s) hidden.

jwbron added a commit that referenced this pull request Feb 25, 2026
Update documentation to reflect changes from commit aa36980:
- Contract decisions (created via egg-contract add-decision) now surface
  as an interactive [q] option in the CLI phase gate menu
- Humans can answer contract questions directly from the terminal
- Phase gate warns if approving with unanswered questions

Updated docs:
- CLAUDE.md: Clarified that contract decisions surface in CLI
- docs/hitl-decisions.md: Added "Contract Decision Bridge" subsection
- docs/guides/sdlc-pipeline.md: Updated phase gate description

Triggered by: #909

Authored-by: egg

Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant