Skip to content

feat(cli): non-interactive prd generate, plus a retry cap so it cannot hang (#1114) - #1135

Merged
frankbria merged 3 commits into
mainfrom
fix/1114-prd-generate-non-interactive
Aug 10, 2026
Merged

feat(cli): non-interactive prd generate, plus a retry cap so it cannot hang (#1114)#1135
frankbria merged 3 commits into
mainfrom
fix/1114-prd-generate-non-interactive

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Closes #1114.

Two modes, because they are not equivalent

flag how it answers survives a rejection?
--brief-file <path> reads each question and answers that question from a project brief yes
--answers-file <path> JSON array consumed in order no

That distinction is the whole issue, and it is why shipping only --answers-file
would not have closed it.

I reproduced the failure the issue describes

The report measured a canned list at 21 turns, 0 accepted, coverage stuck at
0%
. I hit exactly that against a live model with --answers-file — five
substantive, on-topic answers, one rejected as addressing a different question,
and the run ended stranded:

Error: --answers-file ran out of answers after 5 of 5.
Discovery was still on question 2 at 67% coverage.
The questions are AI-generated, so a fixed list can desynchronise if an answer
is rejected. Add more answers, or run interactively to finish the session.

--brief-file, same brief, same model:

✓ PRD generated: Self-Hosted Todo Management REST API
real  1m6.749s          exit 0, no TTY

A complete PRD without a TTY — AC #1 actually satisfied, not satisfied on
paper.

The retry cap is part of the same change, not a nice-to-have

Without it, --answers-file reproduces the original hang with no TTY to
interrupt it
— strictly worse than the interactive version. MAX_ANSWER_ATTEMPTS = 5:

  • interactive — says what is happening ("questions often have two parts"),
    and offers the /pause a stuck user would otherwise have to discover
  • non-interactive — stops and explains that a canned list cannot resynchronise

Loud exhaustion

Running out now raises AnswersExhausted and reports the question number and
coverage it stopped at. Before it was EOF inside Prompt.ask — an opaque
traceback naming neither.

Acceptance criteria

  • A non-interactive input source that produces a PRD without a TTY —
    --brief-file, verified end to end against a live model
  • Fails loudly and specifically when it runs out of answers
  • A retry cap per question that tells the user how to proceed
  • A test drives cf prd generate end to end without a TTY (no input=)
  • scripts/quickstart-cleanroom/ can drop responder.py — see below

22 tests, including the always-rejecting-validator scenario asserting it
terminates instead of looping. ruff clean; tests/cli/: 574 passed.

Judgment calls

  • I did not delete responder.py in this PR. The AC says the harness can
    drop it, and --brief-file is the supported replacement — it is the same idea
    in-process, minus 206 lines of pty/subprocess plumbing. But swapping the Launch: validate the 15-minute quick start from a clean machine + recorded demo #614
    harness over changes what that walkthrough measures (a stand-in user driving
    the real CLI vs. the CLI answering itself), and Launch: validate the 15-minute quick start from a clean machine + recorded demo #614 is someone's evidence
    artifact. That swap deserves its own change with the walkthrough re-run, rather
    than riding along here.
  • JSON array for --answers-file, not one-per-line: real answers are prose
    and wrap. One format keeps the failure modes obvious.
  • --brief-file uses the standard provider resolution chain, so
    --llm-provider / config apply to the stand-in answerer too.

Known limitations

  • --answers-file against a real model is unreliable by nature, not by
    defect. It is documented as such in --help and points at --brief-file. It
    remains the right choice for CI, where the provider is deterministic.
  • --brief-file costs one extra LLM call per question. That is inherent to
    answering the question actually asked.

…t hang (#1114)

`cf prd generate` — the primary THINK entry point and the command the README
leads with — could only be driven by a human at a TTY. So it could not be
covered end to end, could not be demoed reproducibly, and #614's harness had to
build an out-of-process LLM stand-in user (responder.py, 206 lines of pty and
subprocess plumbing) to get through it.

Two non-interactive modes, because they are not equivalent:

--brief-file <path>   Answers each question from a project brief, reading the
                      question actually asked. This is the mode that works
                      against a real model.
--answers-file <path> A JSON array of answers consumed in order. Deterministic
                      and ideal for CI, but a fixed list CANNOT resynchronise
                      once the validator rejects one.

That distinction is the whole issue. The report measured a canned list at 21
turns / 0 accepted / 0% coverage because the questions are AI-generated and
often multi-part. I reproduced exactly that against a live model with
--answers-file: five substantive answers, one rejected as off-topic, and the run
ended at question 2 / 67% coverage. --brief-file on the same brief produced a
complete PRD in 67 seconds with no TTY, exit 0.

Also:
- A retry cap (MAX_ANSWER_ATTEMPTS = 5) per question. Without it --answers-file
  reproduces the original hang with no TTY to interrupt it. Interactive runs are
  told what is happening and offered the pause they would otherwise have to
  discover; non-interactive runs stop and explain why a canned list cannot
  recover.
- Running out of answers raises AnswersExhausted and reports which question and
  what coverage it stopped at, instead of an EOF traceback out of Prompt.ask.
- --answers-file and --brief-file are rejected together, before any API call.

22 tests, including a full discovery run with no `input=` stream at all, and the
always-rejecting validator scenario asserting it terminates rather than looping.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 53s —— View job


Precision bug-hunting review — done

Reviewed codeframe/cli/app.py + the new test module for concrete defects only (logic, error paths, data loss, breaking changes). Style, naming, architecture, and test coverage are out of scope (covered elsewhere).

  • Gather PR context (diff + existing comments)
  • Read changed code in codeframe/cli/app.py and surrounding callers/callees
  • Read the new test file
  • Confirm each candidate defect against real context
  • Post inline findings + summary

2 findings, both minor:

severity file:line finding
minor codeframe/cli/app.py:197 _handle_answer_attempts_exhausted hard-exits --brief-file after 5 rejections with an --answers-file-only message ("a canned answer list… supply answers"). answers.interactive is False for both non-interactive modes, so the can_desynchronise property that was added to tell them apart is unused here — the advice is unactionable for --brief-file and contradicts the PR's "survives a rejection" contract.
minor codeframe/cli/app.py:1790 --answers-file / --brief-file are loaded/validated after session.start_discovery(), which already fires the opening-question LLM call (minutes, per #902). A missing/malformed/empty file therefore wastes a paid call before BadParameter fires — contradicting the PR's own "a flag conflict must not cost an API call" rule that moved the conflict check up.

Both posted as inline review comments with a failure scenario (and a suggestion block on the first). No critical/major defects, no security or data-loss issues, no breaking-contract changes found. Nothing else rose to a confirmable defect — e.g. an uncaught exception from the brief provider's complete() mirrors the existing submit_answer_validate_answer path (also unwrapped), so it is not a regression introduced here.

Comment thread codeframe/cli/app.py Outdated
Comment thread codeframe/cli/app.py Outdated
…1114)

CI caught two repo-wide rules I broke, both real:

- read_text() without encoding= decodes with the locale encoding, so the same
  answers/brief file decodes differently across machines (#1029).
- UnicodeDecodeError subclasses ValueError, not OSError, so it escaped the
  FileNotFoundError-only handlers as a traceback. A latin-1 answers file now
  reports "not valid UTF-8" like every other bad-input case.

Two tests cover the non-UTF-8 paths.

My mistake was running only tests/cli/ before pushing. Full suite now: 6347
passed, 49 skipped.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 4m 33s —— View job


Precision bug-hunting review — re-run on synchronize

Reviewed the new commit 32d11a2 ("pin encoding and handle non-UTF-8 in the new file loaders") for concrete defects only. Scope: logic errors, error paths, data loss, breaking changes — not style/naming/coverage.

  • Gather PR context (full diff + new commit)
  • Check existing comments — the 2 earlier findings are skipped (see below)
  • Read the new encoding code + callers/callees
  • Re-examine the full diff for missed defects
  • Confirm each candidate against real context
  • Post summary

✅ No new defects found in the synchronize commit.

The push is a correct, narrowly-scoped fix to the two file loaders:

  • path.read_text(encoding="utf-8") pins the decode (the [P1.37] Locale-dependent decoding: UnicodeDecodeError escapes OSError-shaped handlers across 55 subprocess sites #1029 locale-dependence rule) — previously read_text() used the locale encoding.
  • except UnicodeDecodeError catches the ValueError subclass that escaped the FileNotFoundError-only handlers (it is a ValueError, not an OSError, so it would have surfaced as a traceback).
  • Exception ordering is sound: in _load_answers_file, UnicodeDecodeError comes from read_text and JSONDecodeError from json.loads — they're mutually exclusive sibling subclasses of ValueError, so the order between them can't misroute an exception.

I checked the candidates that looked alive on the diff and discarded each against real context:

  • _BRIEF_ANSWER_PROMPT.format(...) with braces in the brief/questionnot a defect. str.format only parses the fixed template for {brief}/{question}; substituted values are inserted verbatim and never re-parsed, so a brief containing {, }, JSON, or ${VAR} is safe.
  • Purpose.GENERATION import in _ask_briefnot a defect. Purpose is exported from codeframe.adapters.llm and GENERATION exists (adapters/llm/base.py:53).
  • Empty/whitespace canned answers burning list entries + attempts — bounded (canned list is finite → AnswersExhausted; attempts > MAX_ANSWER_ATTEMPTS → handler), so no hang; behaves as the documented "desynchronise" contract.
  • attempts counter / off-by-one — handler fires on the 6th entry after exactly 5 failed attempts, and the message accurately says 5. Correct.

Still-open (not re-posted)

These two findings were posted inline on the prior commit and are not addressed by this push — the new commit didn't touch either code path. Flagging status only, not repeating the inline comments:

severity location status
minor codeframe/cli/app.py:199-207 (_handle_answer_attempts_exhausted) still open — --brief-file still hits the canned-list-only message; can_desynchronise still unused here
minor codeframe/cli/app.py:1796-1797 (loads vs start_discovery() at 1781/1784) still open — bad/missing/empty input file still wastes the opening-question call before BadParameter

Nothing else rose to a confirmable defect.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Reviewed the current diff (763fd2d + the 32d11a2 encoding fix). The prior automated review already flagged two still-unresolved minor issues in codeframe/cli/app.py — I confirmed both are still present in the latest commit (the encoding-fix commit didn't touch either spot):

  • _handle_answer_attempts_exhausted's non-interactive exit message is hardcoded to "canned answer list" / "supply answers" language, even though --brief-file also goes through the not answers.interactive branch. The can_desynchronise property exists but is never consulted here, so the advice is wrong for --brief-file users.
  • --answers-file/--brief-file are loaded/validated (_load_answers_file / _load_brief_file, app.py:1796-1797) after session.start_discovery() (app.py:1781/1784) already fires the opening-question LLM call, so a missing/malformed file still costs a paid call before BadParameter fires.

Beyond those, I found one more significant issue that I don't think was covered yet:

The MAX_ANSWER_ATTEMPTS retry cap doesn't fire on the exact failure mode this PR describes as the root cause.

In the discovery loop (app.py:1837-1849):

attempts = 0
while True:
    try:
        attempts += 1
        if attempts > MAX_ANSWER_ATTEMPTS:
            ...
        answer = answers.ask(question["text"])
        ...
        result = session.submit_answer(answer)
        if result["accepted"]:
            break
        else:
            console.print(f"[yellow]{result['feedback']}[/yellow]")
            if result.get("follow_up"):
                break   # <-- exits the inner loop without ever re-checking attempts
            # otherwise let user try again

attempts is a local counter reset to 0 at the top of every outer-loop iteration. PrdDiscoverySession.submit_answer (core/prd_discovery.py:450-457) sets self._current_question = validation["follow_up"] whenever the validator rejects an answer but supplies a follow-up — and question_number (len(self._qa_history) + 1) doesn't advance in that case, since nothing was accepted. So a follow-up is the same logical question, just reworded, but the CLI's inner while True breaks back to the outer loop and attempts resets to 0.

The validator's own prompt (core/prd_discovery.py:127-146) generates a follow_up precisely when an answer "leaves major gaps" but isn't "truly unhelpful" — i.e. exactly the multi-part-question case this PR's own description names as the root cause ("a user who keeps missing a sub-clause of a two-part question"). If the validator keeps responding with a follow-up instead of a flat rejection, attempts never exceeds 1 and the cap never trips:

  • For --answers-file, this isn't a true hang — it's masked by AnswersExhausted once the canned list runs dry — but the cap is not what stops it, so the user gets "ran out of answers" instead of the more accurate "stuck on this question" message, and a long-enough answers file would burn through many entries on a single stuck question before failing.
  • For --brief-file, _ask_brief never raises AnswersExhausted — there's no fixed list to exhaust. With no cap protecting it, this is an unbounded loop of real, billed LLM calls with no TTY to interrupt it — the exact scenario the PR states the retry cap exists to prevent ("Without it, --answers-file reproduces the original hang with no TTY to interrupt it... strictly worse than the interactive version").

The existing test for this (TestARejectingValidatorDoesNotHang) doesn't catch it because its always_rejects fixture hardcodes "follow_up": None — it only exercises the flat-rejection path, not the follow-up path.

Suggest tracking attempts per logical question rather than per outer-loop pass — e.g. don't reset attempts when result.get("follow_up") is truthy (treat it as a continued attempt at the same question), or key the counter off question_number/qa_history length instead of loop iteration. Would also be worth adding a test where the mock validator always rejects with a follow-up, to confirm the cap still trips for both --answers-file and, especially, --brief-file.

Everything else looks solid: the _AnswerSource abstraction is a clean seam, the encoding/UTF-8 handling added in the second commit is correct and well tested, escape() is used consistently on echoed answers, and the --answers-file/--brief-file mutual-exclusion check is appropriately early (before any API call). Test coverage is otherwise thorough for the modes as designed.

…ore any call (#1114)

Both from PR review, both real.

1. The retry-cap handler gave --brief-file runs an --answers-file message: it
   told them a "canned answer list cannot recover" and to "supply answers that
   address every part of the question". Brief mode has no list and takes a
   brief, so that advice is unactionable — and it contradicts this PR's own
   claim that --brief-file survives a rejection. Worse, can_desynchronise was
   added precisely to tell the two modes apart and then never used. It is now
   the branch: --answers-file is pointed at --brief-file, and --brief-file is
   told the brief probably does not cover the question, quoting it.

2. The input files were loaded after session.start_discovery(), which generates
   the opening question — a paid call taking minutes (#902). So a typo in a path
   cost a request before failing. This contradicted the comment three lines
   above the conflict check ("a flag conflict must not cost an API call"), which
   I had applied to the flags and not their contents. Both loads moved up; two
   tests assert create_provider is never called on a bad path.

The brief-mode message test exercises the handler directly. Driving it through
the CLI hung inside the discovery engine's own retry loop, which would have
meant mocking the whole engine to assert a message string.

Full suite: 6351 passed, 49 skipped.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 12s —— View job


Precision bug-hunting review — re-run on synchronize

Reviewed commit 3dfd830 ("mode-appropriate retry advice, and validate input files before any call") for concrete defects only. Scope: logic errors, error paths, data loss, breaking changes — not style/naming/coverage.

  • Gather PR context (full diff + new commit)
  • Check existing comments — earlier findings skipped (see below)
  • Read the changed code + callers/callees (app.py:1747-1937, core/prd_discovery.py:391-481, 127-146)
  • Re-examine the full diff for missed defects
  • Confirm each candidate against real context
  • Post summary

✅ No new defects in 3dfd830. Both earlier inline findings are confirmed resolved by this push:

earlier finding status evidence
--brief-file hit the --answers-file-only message resolved _handle_answer_attempts_exhausted now branches on answers.can_desynchronise (app.py); --answers-file → points at --brief-file, --brief-file → "extend the brief". The can_desynchronise property is now actually consulted. Logic verified: can_desynchronise (_answers is not None) and interactive (_answers is None and _brief is None) are mutually exclusive, so all three modes route distinctly.
input files loaded after start_discovery() (paid call) resolved _load_answers_file/_load_brief_file now run at app.py:1758-1759, before get_workspace (1764) and start_discovery() (1798/1801). The loads only read disk — no workspace dependency — so the reorder is safe.

Still-open — the follow-up retry-cap gap (not re-posted, status only)

This was raised in the prior review round (the claude comment) and is not addressed by 3dfd830 — the commit's own message lists only the two fixes above. Confirming it is still live in the current tree:

attempts resets to 0 at the top of the outer discovery loop (app.py:1853), and when the validator rejects an answer with a follow_up, submit_answer swaps _current_question in place (prd_discovery.py:450-458) and the CLI breaks the inner loop (app.py:1902-1906) — so the counter starts over without ever crossing MAX_ANSWER_ATTEMPTS. The validator prompt emits exactly that follow_up for an answer that "leaves major gaps" but isn't "truly unhelpful" (prd_discovery.py:135,142,145-146) — i.e. the multi-part-question case this PR names as the root cause.

  • --brief-file: _ask_brief never raises AnswersExhausted, so nothing bounds it — an unbounded loop of billed LLM calls (two per turn: the brief answer + the validation) with no TTY. That is the precise hang the cap was added to prevent ("strictly worse than the interactive version").
  • --answers-file: terminates, but via AnswersExhausted once the list runs dry — so the user gets "ran out of answers" rather than "stuck on this question", and a long file burns one entry per follow-up on one stuck question.
  • Why tests pass: TestARejectingValidatorDoesNotHang's always_rejects fixture hardcodes "follow_up": None (tests/cli/...:220), so only the flat-rejection path is exercised.

Suggested direction (from the prior comment): track attempts per logical question — don't reset when result.get("follow_up") is truthy, or key the counter off question_number/_qa_history length — and add a test where the mock validator always rejects with a follow_up.

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review

Confirmed the two issues flagged in the earlier automated reviews (mode-mismatch retry advice, and file validation happening after the paid start_discovery() call) are both fixed by the third commit (3dfd830c):

  • _handle_answer_attempts_exhausted now branches on answers.can_desynchronise (codeframe/cli/app.py:148-167), so --brief-file gets brief-appropriate advice and --answers-file keeps its own — can_desynchronise is no longer dead.
  • _load_answers_file/_load_brief_file now run before session.start_discovery() (codeframe/cli/app.py:1757-1758 vs. start_discovery() further down at ~1786), and TestReviewFindings::test_a_bad_*_file_costs_no_llm_call locks that in.

Nice fixes, and the encoding pin (encoding="utf-8" + UnicodeDecodeError handling) in the second commit is correct per repo rule #1029.

One finding still open: the retry cap doesn't fire on the failure mode this PR names as the root cause

attempts is a local counter that resets to 0 at the top of the outer discovery loop (codeframe/cli/app.py:1852-1853), and it's only incremented inside the inner while True. But when the validator rejects an answer with a follow-up, the inner loop breaks back out to the outer loop instead of retrying (codeframe/cli/app.py:1900-1906):

if result.get("follow_up"):
    console.print("\n[cyan]Let me ask differently:[/cyan]")
    break   # -> outer loop, attempts resets to 0

PrdDiscoverySession.submit_answer sets self._current_question = validation["follow_up"] in exactly this case (codeframe/core/prd_discovery.py:450-452) without appending to _qa_history, so question_number doesn't advance — it's the same logical question, just reworded. The validator's own prompt generates a follow_up precisely when an answer "leaves major gaps" but isn't "truly unhelpful" (core/prd_discovery.py:127-146) — i.e. the multi-part-question case this PR's description calls out as the actual root cause of the reported 21-turn/0%-coverage hang.

So as long as the validator keeps returning a follow-up instead of a flat rejection, attempts never exceeds 1 and MAX_ANSWER_ATTEMPTS never trips. There's no other bound on discovery length in core/prd_discovery.py — this CLI-level counter is the only thing meant to prevent an unbounded loop.

  • For --answers-file, this is masked by AnswersExhausted once the (finite) canned list runs dry, but the cap isn't what stops it — a long-enough file would burn many entries on one stuck question before failing, and the eventual error would misleadingly say "ran out of answers" rather than "stuck on this question."
  • For --brief-file, _ask_brief has no fixed supply to exhaust and no cap protecting it, so this is a genuinely unbounded loop of real, billed LLM calls with no TTY to interrupt — the exact scenario the PR states the retry cap exists to prevent, on the mode this PR pitches as the reliable one against a real model.

TestARejectingValidatorDoesNotHang doesn't catch this because its always_rejects fixture hardcodes "follow_up": None, so it only exercises the flat-rejection path.

Suggest tracking attempts per logical question rather than per outer-loop pass — e.g. don't reset attempts when result.get("follow_up") is truthy, or key the counter off question_number/qa_history length instead of loop iteration — plus a test where the mock validator always rejects with a follow-up, to confirm the cap still trips for both modes (especially --brief-file, where the cost of missing it is highest).

This finding was raised in a previous review comment on this PR (posted before the third commit) and, based on reading the current code, is still present in the latest commit — the third commit's fixes were scoped to the other two issues and didn't touch this loop.

Everything else

  • _AnswerSource is a clean seam — good call keeping if non_interactive out of the discovery loop body.
  • _BRIEF_ANSWER_PROMPT.format(...) is safe against braces/JSON in the brief or question text (str.format only parses the template, not the substituted values).
  • escape() is applied consistently to echoed answers before printing.
  • The --answers-file/--brief-file mutual-exclusion check fires before any file I/O or API call, as intended.
  • Test coverage for the modes as designed (happy path, exhaustion, encoding, mutual exclusion) is thorough; the gap is specifically the follow-up/retry-cap interaction above.

No security concerns — this only reads local files supplied via CLI flags and calls the already-trusted LLM provider chain.

@frankbria
frankbria merged commit 2e288e8 into main Aug 10, 2026
20 checks passed
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.

[P2.30] cf prd generate has no non-interactive mode — untestable in CI and undemoable

1 participant