Skip to content

fix(detect --stories): gate stories on per-criterion verdicts, not model silence - #2390

Open
agarwal-ishaan wants to merge 22 commits into
promptdriven:mainfrom
agarwal-ishaan:fix/issue-2389-story-criteria-gate
Open

fix(detect --stories): gate stories on per-criterion verdicts, not model silence#2390
agarwal-ishaan wants to merge 22 commits into
promptdriven:mainfrom
agarwal-ishaan:fix/issue-2389-story-criteria-gate

Conversation

@agarwal-ishaan

@agarwal-ishaan agarwal-ishaan commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #2389

Problem

run_user_story_tests passes a story only when detect_change returns an empty changes list:

passed = len(changes_list) == 0 and not evaluation_incomplete

detect_change is the change-propagation detector. Its prompt asks the model to "analyze which prompts need to be changed" and to "generate at least three different possible implementation plans" — it is never asked whether anything is wrong. Using an empty answer as the pass criterion makes the verdict depend on model strength rather than on whether the prompts satisfy the contract.

Three runs in #2389, same story, same contract, same seven prompts, only --strength varied: a weak model FAILed twice (once on genuine gaps, once on noise — asking to strengthen a rule whose text already said the thing verbatim), a strong model PASSed. Nothing about the prompts changed between runs.

There is a second face to the same bug: a malformed or empty detector response also produces an empty changes list, so an evaluator that said nothing at all was scored as success. The gate failed open.

Change

Story validation now asks a bounded question. Each acceptance criterion is classified exactly once.

before after
pass condition empty changes_list no criterion unsatisfied
model hedges FAIL PASS + advisory warning
model returns nothing PASS UNKNOWN, incomplete
evidence prose suggestions per-criterion verdict + quoted prompt text
provenance not shown "judged by <model>" in output
  • Only unsatisfied fails a story. unclear is emitted as a criteria:UNCLEAR warning and never moves the verdict, so a hedging model cannot fail a correct prompt set.
  • A criterion the evaluator left unjudged, or an evaluator error, marks the run incomplete rather than passing it, and deliberately does not fall back to detect_change — whose empty answer would read as success.
  • A satisfied verdict must quote prompt text; without a citation it degrades to unclear. Citations that aren't found verbatim are reported (criteria:UNVERIFIED_CITATION) but never fail a criterion, since models paraphrase and a false alarm there would reintroduce the model sensitivity this removes.
  • An unrecognized or empty status becomes unclear, never satisfied.

Compatibility

  • detect_change is untouched. It still owns change propagation, PRD propagation, and pdd fix.
  • Criteria are read from the story contract, else the story itself. A story with no parseable criteria keeps the legacy gate, so contract-less stories behave exactly as before.
  • --legacy-detect (CLI) and legacy_detect=True (library) force every story onto the old gate.
  • run_user_story_tests keeps its (bool, list, float, str) signature, so drift_main, agentic_change_orchestrator, change_main, and commands/analysis need no changes.
  • Unsatisfied criteria render into the existing {prompt_name, change_instructions} rows, and unclear lands in the existing advisory warnings channel — pdd.detect.stories.v1 is unchanged, no schema bump.

Tests

865 green: 99 story/criteria (30 new unit + 8 new integration), 731 across detect, orchestrator, drift, architecture-sync, evidence and CLI suites, 34 test_sync_core_manifest.py, 81 test_sync_core_verification_profiles.py.

Known-red CI, and what it needs from a maintainer

7 tests in tests/test_sync_core_pdd_rollout_policy.py fail, all from one cause: this PR adds two files under pdd/prompts/, which automatically become managed units, and they are not registered.

artifact sha256
new profiles state 13933430441ceafd14232eff53e8456628f70ea5a18fd6c233cb40f18547c830
protected base profiles a7dd6a8f4145e2a4712ca925abde0edc58c47be271a1a06e1750aa39347a76c7
story_criteria_LLM.prompt 502768e905f49b587ac821e520002019e758a79aba9fda89c0bf73c7488e3f5d
story_criteria_python.prompt e0514f4abcc03f6c41f3ef3cf8978bdb0f4e732d3d8c306f7618469762d8ee4b

Remaining steps, all naming the new state:

  1. Add an authorized (base, head) pair beside _SYNC_ROLLOUT_REPAIR_PROFILE_BYTES (verification.py:127) and to the set at tests/test_sync_core_pdd_rollout_policy.py:2268.
  2. Add requirement_rotations rows in .pdd/verification-profile-rotations.json with head_policy_sha256 set to the new digest — their absence is why current_rows is [] at line 2565.
  3. The requirement transition bindings mismatch cascade shares this root and clears once 1 and 2 land.
  4. test_pdd1875_phase_a_is_dormant_on_its_composed_head (:2192) and test_replay_transitions_cover_the_actual_protected_base (:2212) assert against a historical head that genuinely holds 469 units; give them their own frozen constant following PDD_1989_EXPECTED_MANAGED_UNITS (:159), leaving EXPECTED_MANAGED_UNITS = 471 for current.

The new-profiles digest holds only while .pdd/verification-profiles.json stays byte-identical to that branch; any reformat re-mints it.

@agarwal-ishaan

Copy link
Copy Markdown
Collaborator Author

Live-model validation

The PR body noted this had only been exercised with stubbed evaluators. It has now been run end to end against a real provider, deliberately at the weak-model condition from the issue: gemini-3-flash-preview at --strength 0.2 — the same model and strength that produced the noisy FAIL in run 2 of #2389.

1. Positive — a correct prompt set no longer fails on a weak model

Story story__pdd_bug.md against prompts/commands/analysis_python.prompt:

PASS story__pdd_bug.md
  Acceptance criteria (from contract), judged by gemini/gemini-3-flash-preview:
  SATISFIED    AC1: ... dispatches to the agentic bug workflow ...
               evidence: Agentic mode (first arg is URL): Call `run_agentic_bug(issue_url, verbose=..., quiet=..., timeout_adder=..., use_github_state=..., clean_restart=...)`.
  SATISFIED    AC2 ... AC3 ... AC4

All four carried verbatim citations, and none was flagged (not found verbatim) — the citation check found every quote in the prompt text. Cost $0.0036.

2. Negative control — the gate still discriminates

A gate that only ever says PASS would be no better than the one it replaces. I injected a fifth criterion the prompt demonstrably does not satisfy ("sends an SMS notification to the on-call engineer ... and waits for an acknowledgement code"):

UNSATISFIED  AC5: ... sends an SMS notification to the on-call engineer's mobile phone ...
             The prompt contains no mention of SMS notifications or an on-call
             engineer acknowledgement process.

Verdict flipped to FAIL, exit non-zero, and AC1–AC4 stayed SATISFIED — the failure is localized to the criterion that broke rather than smeared across prose. Cost $0.0036.

3. Machine-readable path

--json-output on the failing run produced a document that validates against the unchanged pdd/schemas/story_detection_result.schema.json:

schema_version : pdd.detect.stories.v1
outcome        : STORY_FAILURE
verdict        : FAIL
changes[0]     : AC5 is not satisfied: Given any invocation of `pdd bug` ...
item keys      : changes, contract, cost_usd, errors, linked_prompts, story, verdict, warnings
VALID against pdd/schemas/story_detection_result.schema.json

Confirming the compatibility claim in the PR body: unsatisfied criteria land in the existing changes rows, so pdd fix and any consumer of the v1 document keep working with no schema bump.

Total spend for all three runs: $0.011.

The known-red CI (the two unregistered prompt units) is unaffected by this and still needs the registration described above.

@agarwal-ishaan

Copy link
Copy Markdown
Collaborator Author

Controlled comparison: old gate vs new gate

Rather than assert this is better, I ran both gates over the same story, contract and prompt, varying only (a) the model strength and (b) whether the prompt contains a real regression.

Setup. Story story__pdd_bug.md + its contract (4 acceptance criteria) against prompts/commands/analysis_python.prompt. The "regressed" variant deletes every statement constraining --clean-restart — both the MUST raise click.UsageError sentence and the "agentic mode only" qualifier — so AC3 (reject --clean-restart outside agentic GitHub issue mode) is genuinely unsatisfied while AC1/AC2/AC4 are untouched.

Results

strength model new / correct new / regressed legacy / correct legacy / regressed
0.2 gemini-3-flash-preview PASS PASS ❌ (AC3=UNCLEAR, 4/4) FAIL ❌ FAIL
0.5 gemini-3.5-flash PASS FAIL ✅ (AC3=UNSATISFIED, 4/4) FAIL ❌ FAIL
0.7 gemini-3.5-flash PASS FAIL ✅ (2/2)
0.9 gemini-3.5-flash PASS FAIL ✅ (2/2) FAIL ❌ FAIL

Total spend across all runs: $0.63.

1. The legacy gate has no discriminating power at any strength

It returned FAIL in every condition — including on correct prompt sets, including at strength 0.9. Its FAIL on a correct prompt set is indistinguishable from its FAIL on a regressed one, so the verdict carries no information about the prompts.

This is worth stating plainly because it is stronger than what #2389 claims. The issue describes "weak model fails, strong model passes"; in this experiment the legacy gate never passed anything at any strength. Pinning a floor strength — the issue's fallback proposal — would not have fixed it.

2. Why the legacy gate fails correct prompts

Structural, not incidental. detect_change_LLM.prompt is a change-propagation planner, not a checker:

line 1:  "analyze which prompts need to be changed based on the change description,
          and provide detailed instructions on how they should be changed"
line 44: "Generate at least three different possible implementation plans"
line 47: "List the prompts that need to be changed based on the selected plan"

It hedges once (line 35: "Some prompts maybe unaffected ... or already have the changes applied"), but that is a single clause inside a task whose output format has no no-change branch.

Compounding it: in story mode the "change description" is the story + contract. The model is handed a specification and asked how it would edit the prompts to apply it. Restating a contract inside a prompt is always a plausible edit, even when the prompt already satisfies it — so the question is close to unanswerable with "nothing".

3. Two honest caveats

(a) A false negative at the weakest tier. At strength 0.2 the regressed set passed, reproducibly (4/4). The model returned UNCLEAR on AC3 — an honest "I cannot decide" — and this PR maps unclear to advisory, so the verdict became PASS. That is the fail-open point moving rather than closing: from "the model said nothing" to "the model could not decide".

I intend to fix this by using the outcome the schema already has, unless reviewers prefer otherwise:

  • any unsatisfied → FAIL (unchanged)
  • no unsatisfied but some unclearINCOMPLETE ("not verified", not "verified fine")
  • all satisfied → PASS

unclear still must never FAIL a story — that would reimport the strength-sensitivity this PR removes. The correct set was all-SATISFIED at every strength, so this costs nothing on healthy prompts.

(b) This PR gates on ## Acceptance Criteria only, not ## Negative Cases. The experiment surfaced this: legacy's complaint on the correct set was that the prompt never explicitly requires rejecting non-issue GitHub URLs — which is real, and is written in the contract's Negative Cases, not in an acceptance criterion. The legacy oracle saw the whole contract; this one classifies only the numbered criteria. A violated negative case that is not also an AC would not fail the story. Worth a follow-up to classify negative cases as "must not" criteria.

Reproducing

Strengths 0.5, 0.7 and 0.9 all resolved to gemini-3.5-flash in my environment (Anthropic credits exhausted, so the Claude tiers failed over). So this is two model tiers, not four — a Sonnet-class model is untested, and the 0.5-vs-0.9 comparison is not informative. The 0.2-vs-rest contrast is real.

@agarwal-ishaan

Copy link
Copy Markdown
Collaborator Author

Two scope updates on this PR.

Reduced to the engine swap alone. An earlier revision added a pdd story verify subcommand as a second front door. That has been removed: pdd detect --stories keeps its name, options, JSON schema and exit codes, and only the engine underneath changed. The command was worth building — a second front door is how the exit-code inconsistency in it was found — but it was not needed for the fix, and it dragged an ownership rule, a digest re-pin and a docs rename with it. The sync-ownership digest returns exactly to b23599ec…, the value pinned for the test_story_criteria rule, so no stale pin is left behind.

Where verification belongs in the command tree is a design question that can be argued without blocking the fix.

Strength default split out to #2394. While measuring this gate I found that story validation hardcodes --strength 0.2 in five signatures while DEFAULT_STRENGTH is 1.0, and ctx.obj["strength"] is only populated when --strength is passed explicitly — so every plain pdd detect --stories evaluates at 0.2. That is the one tier where the new gate measurably fails to discriminate: it passed a genuinely regressed prompt set 5 runs out of 5, with the broken criterion confidently reported satisfied rather than undecided.

That is a pre-existing default, not something this PR introduces, and fixing it changes cost characteristics for every caller — so it is tracked separately rather than folded in here.

Net effect: this PR now changes the engine and nothing else. detect_change itself is untouched and still serves pdd detect, pdd change, PRD propagation, story prompt-link discovery, and stories with no parseable criteria (plus --legacy-detect).

@gltanaka gltanaka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes. This PR solves a real structural problem: detect_change is a propagation planner, so an empty suggestion list is not a sound story oracle. Per-criterion classification is the right direction, and the new code, prompts, architecture entry, and managed-unit registrations are present. The current head is not safe or repository-accepted yet.

  1. P1 — the default command uses the one tier proven to pass a real regression. pdd/commands/analysis.py:664 and pdd/user_story_tests.py:1793 select strength 0.2. The live negative control recorded here and in #2394 passed the broken prompt set 5/5 at that default, confidently as satisfied. Plain pdd detect --stories can therefore return exit 0 for the known regression. At minimum land the stronger default before this engine becomes active and ensure below-floor results cannot look silently trustworthy.

  2. P1 — criteria-mode link caching drops prompts that satisfied other criteria. At pdd/user_story_tests.py:2012-2018, missing metadata is derived from changes_list, but that list now contains only unsatisfied verdicts. Reproduction: evaluate A+B, let AC1 be satisfied by A and AC2 fail in B; the first run writes only b_python.prompt into pdd-story-prompts, and the next run never evaluates A. Preserve the full evaluated scope in criteria mode, or derive links from every verdict with a full-scope fallback.

  3. P1 — protected rollout policy is red. tests/test_sync_core_pdd_rollout_policy.py failed on the 471-vs-469 managed-unit count, verification-profile requirement mismatch for the modified user_story_tests_python.prompt, and invalid protected inputs. Install the required verification profiles/rotations and separate current versus frozen historical inventory expectations. The branch also conflicts with current main in pdd/sync_core/manifest.py; resolution must preserve the current 8ff5fb..., PR b23599..., and protected historical 558910... authorized pairs as applicable, then rerun the policy suite.

  4. P2 — duplicate basenames make repair targeting ambiguous. pdd/story_criteria.py:432 sends only path.name; pdd/user_story_tests.py:146 overwrites duplicate basename keys. A two-directory same_python.prompt reproduction resolved the model response to the last file. The repository already has duplicate auth_python.prompt, cli_python.prompt, and gate_python.prompt names. Send unique relative identifiers and reject ambiguous basename-only targets.

  5. P2 — a partially unreadable scope can still pass. pdd/story_criteria.py:511-526 downgrades only unsatisfied verdicts when a linked file was unreadable. With one readable and one invalid-UTF-8 linked prompt plus a satisfied assessment from the readable file, the result was passed=True and verified=True. Any unreadable linked input must make the evaluation incomplete because the model did not evaluate the declared scope; add the satisfied-response regression case.

  6. P2 — architecture metadata is incomplete/stale. architecture.json:10111-10113 omits the public guards parameter, the function list omits public parse_non_oracle_guards, and architecture.json:10135 still says story validation runs via detect_change. Align it with pdd/prompts/story_criteria_python.prompt:74-83 and the generated module.

Validation:

  • 223 passed across criteria, story orchestration, stable result schema, and analysis CLI.
  • 460 passed across caller/change/drift/orchestration/manifest suites.
  • Rollout policy established 3 failures after 23 passes.
  • git diff --check passed.
  • Targeted pylint is not clean on the new evaluator and expanded fix path.

The contributor did useful live positive, negative, and JSON-schema checks, but the E2E evidence is not sufficient for merge because its default-tier negative control fails and the protected repository gate is red. No contributor branch files were modified.

@gltanaka gltanaka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Follow-up review of 00d9e1d: the new commit correctly fixes four earlier findings—unique prompt identifiers/ambiguous basename rejection, partial unreadable-scope handling, architecture metadata, and full-scope caching after a completed criteria evaluation. The remediation suite is green, but this is still not mergeable.

  1. P1 — incomplete criteria evaluation can still narrow cached scope. At pdd/user_story_tests.py:2035-2048, full scope is preserved only when evaluation is not None. If evaluate_acceptance_criteria raises StoryCriteriaError, criteria_error is recorded but the caching branch falls back to _select_story_prompt_links. Reproduction with prompts A+B and story prose mentioning only A: the current run is correctly UNKNOWN, but metadata is rewritten to only a_python.prompt; the retry no longer evaluates B. When parsed criteria exist, cache story_prompt_files regardless of evaluator success, or do not mutate metadata on incomplete runs. Add this evaluator-error regression.

  2. P1 — protected rollout remains red. The exact inventory test still fails at tests/test_sync_core_pdd_rollout_policy.py:525 because the PR registers 471 managed units while EXPECTED_MANAGED_UNITS at line 94 remains 469. Complete the current-vs-historical inventory transition and run the full rollout-policy suite.

  3. P1 — the branch still conflicts with current main in pdd/sync_core/manifest.py. The auto-merged .pdd/sync-ownership.json bytes hash to 4539337c69291dfcab7bf6913183a39fb61a96553ad3c9ee6751b737f03acf18, so choosing either side's existing current digest would be stale. Re-pin the merged current pair while retaining required frozen historical pairs, then rerun rollout gates.

  4. P1 — the default trust issue remains operationally unresolved. pdd/commands/analysis.py:664 and pdd/user_story_tests.py:1816 still use strength 0.2, the tier shown in #2394 to confidently pass the known broken prompt set 5/5. The separate fix in PR #2398 is still open, Changes Requested, and has a failing unit-test check. Do not activate this gate in a merge until the stronger default/floor is integrated and green.

Validation on the updated head:

  • 227 passed: criteria, user-story orchestration, stable result schema, analysis CLI.
  • 313 passed: change, drift, and agentic-change callers.
  • Focused rollout: 3 passed, exact inventory failed 471 vs 469.
  • Independent delta review reproduced the new evaluator-error caching hole.
  • git diff --check passed.
  • GitHub reports mergeable=CONFLICTING.

So the design still makes sense, and the update is meaningful, but merging it now would leave a known partial-scope false-pass path, a red protected gate, an unresolved semantic conflict, and a default-tier false negative.

agarwal-ishaan and others added 9 commits August 13, 2026 21:03
…del silence

`run_user_story_tests` passed a story only when `detect_change` returned an
empty changes list. `detect_change` asks an open-ended "what would you change?",
so the pass bar was "the model volunteered nothing" -- a question LLMs are
strongly biased to answer with something. The verdict tracked model strength
rather than the prompts: the same story, contract, and prompts flipped FAIL to
PASS on `--strength` alone, and a weak model failed a correct prompt set with
suggestions that were already satisfied verbatim.

Story validation now asks a bounded question instead. Every acceptance
criterion is classified exactly once as satisfied/unsatisfied/unclear, and a
satisfied verdict must quote the prompt text that earns it.

- only `unsatisfied` fails a story; `unclear` is emitted as a warning so a
  hedging model cannot fail correct prompts
- a criterion left unjudged, or an evaluator error, marks the run incomplete
  rather than passing it -- the legacy gate read silence as success, and does
  not fall back to `detect_change`, whose empty answer would do the same
- criteria come from the story contract, else the story itself; a story with
  no parseable criteria keeps the legacy gate
- `--legacy-detect` / `legacy_detect=True` forces the old gate
- non-quiet output reports each criterion and names the judging model, so a
  cheap-model PASS is not mistaken for a strong one

`detect_change` itself is untouched and still owns change propagation and
`pdd fix`. Unsatisfied criteria are rendered into the existing
`{prompt_name, change_instructions}` rows, so `pdd fix`, story-link caching,
and the `pdd.detect.stories.v1` document need no changes -- `unclear` lands in
the existing advisory warnings channel, so the JSON schema is unchanged.

Fixes promptdriven#2389

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New tracked files need explicit entries or the sync manifest reports them as
unaccounted:
- pdd/prompts/story_criteria_{LLM,python}.prompt -> expected-managed units
- tests/test_story_criteria.py -> sync-ownership rule

pdd/story_criteria.py needs no rule: its architecture.json entry makes it a
prompt-backed managed output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Models often return criterion_id as "AC1: Given a valid CSV..." rather than a
bare "AC1". The end-anchored pattern dropped those, turning a real verdict into
a false 'unevaluated' and marking a complete run incomplete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding the tests/test_story_criteria.py ownership rule changes the sha256 of
.pdd/sync-ownership.json, which _sync_rollout_repair_rules pins. Without the
re-pin the bridge falls through to base_rules and eight .pdd/meta paths
silently lose their repaired ownership.

The previously authorized head is kept alongside the new one: tests drive the
bridge with different head refs, some frozen historically. Both pairs bind the
same reviewed base.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Editing an existing pdd/prompts/*.prompt invalidates its CONTRACT-SHA256
requirement id, which surfaces as 'profile requirements do not match immutable
prompt requirements'. docs/ci.md requires prompt transitions to be installed and
consumed in two separate merged changes, so this spec update belongs in a
follow-up PR.

The behavior ships in pdd/user_story_tests.py and the tests exercise that
artifact, so reverting the spec text costs nothing functionally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on a live weak model (gemini-3-flash-preview at --strength 0.2), a
genuinely regressed prompt set PASSED, reproducibly 4 runs out of 4. The model
had returned `unclear` on the criterion the regression broke -- an honest "I
cannot decide" -- and this gate mapped `unclear` to advisory, so the verdict
came out green.

That is the same fail-open shape the gate was written to remove, just relocated:
from "the model said nothing" to "the model could not decide".

README's documented contract is explicit that exit 0 means every scoped story
"explicitly passed", and it already reserves exit 3 for an incomplete
evaluation, with UNKNOWN as a first-class per-story verdict. So this is
conformance with the documented behaviour rather than a new state:

- any `unsatisfied`            -> FAIL
- no `unsatisfied`, some `unclear` -> INCOMPLETE (exit 3), story not verified
- all `satisfied`              -> PASS

`unclear` still never FAILS a story. Letting it fail would reimport exactly the
model-strength sensitivity this gate exists to remove -- a hedging model must
not be able to fail a correct prompt set. It simply no longer claims success.

Verified live: the correct prompt set stayed PASS at every strength tested
(0.2/0.5/0.7/0.9), so this costs nothing on healthy prompts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…at owns it

Gating acceptance criteria alone passes a story it should not. Measured on
`story__pdd_bug`: the prompts satisfy all four acceptance criteria, so an
AC-only gate returns PASS -- while the same contract's negative cases and
oracle details go unchecked. Deleting the `--clean-restart` constraints leaves
AC-only reporting a single failure, where the full check reports five
(AC3, NC1, NC2, NC3, OR3), each traceable to the deleted text.

All three checkable families are now criteria, and the fourth section becomes a
constraint on the judge rather than something judged:

  ## Acceptance Criteria -> AC<n>  do the prompts REQUIRE this?
  ## Negative Cases      -> NC<n>  do the prompts PREVENT this?
  ## Oracle              -> OR<n>  do the prompts DETERMINE this detail?
  ## Non-Oracle          -> guards never grounds for unsatisfied

Non-Oracle as a prohibition is the point: the contract already names what it
considers irrelevant -- wording, private helper names, styling -- so the judge
is constrained by the story's own declaration instead of by generic "do not
nitpick" instructions. That attacks the legacy detector's noise structurally.

A satisfied citation must now quote text that REQUIRES something. Measured
failure it closes: a wrong `satisfied` cited "Options: --manual, --output,
--language, ..." -- verbatim, so the existing check passed, but an option list
does not require rejecting an option.

Adds `pdd story verify`, beside `story add`/`link`/`list`. Verification is not a
mode of the change detector: `detect --stories` carries its own schema,
verdicts, exit codes and read-only semantics, but lived under a command named
for a different job. It delegates to the same implementation, so there is one
engine and two front doors; `detect --stories` keeps working unchanged.

Verified live at --strength 0.5: correct prompt set PASS with all 13 criteria
satisfied; regressed set FAIL naming the five that broke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
agarwal-ishaan and others added 8 commits August 13, 2026 21:05
…tector

Where a story has a contract, that contract IS the specification. Asking
`detect_change` "what would you change?" re-derived, less precisely, what the
contract already states -- and the answer was discarded: `change_instructions`
is read in exactly one place in this module (`_format_story_change`, for
printing), while `change_main` receives the story+contract oracle. The detect
step spent 2 LLM calls to produce a plan nobody read, keeping only prompt names.

Repairs are now planned from the same bounded verification that judges them, so
each edit traces to a named unsatisfied criterion and the story-fix path drops
from 3 LLM calls to 1.

Also closes the last instance of the original fail-open. Previously an empty
change list returned "No prompt changes needed" and short-circuited before the
final validation, so a silent or malformed detector reported success having
changed nothing and checked nothing. An empty change list is now only good news
when every criterion was actually judged; an undecided evaluation reports that
the story could not be verified instead of claiming it is clean.

`detect_change` is unchanged and still used here for stories with no parseable
criteria, and via `legacy_detect`. It remains the engine for `pdd change`,
standard `detect`, PRD propagation, and story prompt-link discovery -- which
asks "which prompts is this story about?", a question verification cannot
answer because it must be told which prompts to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README and the story guide described `detect --stories` as the only spelling
and did not say what verification actually checks. They now document the four
contract sections (AC require / NC prevent / OR determine, Non-Oracle as a
guard), the citation requirement, and that unclear leaves a story UNKNOWN
rather than passed. `detect --stories` is documented as the older spelling and
kept in the CI/scope-manifest examples, which callers already use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. HIGH -- `pdd story verify` exited 0 on a story FAIL. `verify_stories`
   discarded the result of `ctx.invoke`, but a plain FAIL is signalled by
   RETURNING the payload to the group's result callback; `detect`'s own
   `raise Exit(1)` is guarded on `ctx.parent is None` and cannot fire through
   an invoke. Reproduced (verify exit=0 vs detect exit=1), fixed by returning
   the payload, and pinned by a test asserting the two front doors agree.

2. Undecided criteria were reported to automation as a broken prompt link. The
   incomplete reason contained the word "prompt", and story_detection_result
   classifies row errors by substring, so an undecided story emitted
   `prompt:UNRESOLVED_LINK` ("A linked prompt could not be resolved") and sent
   operators hunting for a reference that does not exist. Reworded, with a test
   asserting the reason never contains "prompt".

3. The generating prompt contradicted the module it generates -- it still said
   `unclear` MUST NOT affect the verdict, and knew nothing of negative cases,
   oracle criteria, guards, or `verified`. In this repo the prompt is the
   regeneration source of truth, so `make generate` would have restored the
   fail-open this PR removes.

4. Conflicting duplicate assessments kept the first, so a model that corrected
   itself from `satisfied` to `unsatisfied` still passed. Now takes the worst
   status, matching every other ambiguity in the module.

5. Unreadable linked prompts were dropped silently and read as missing
   requirements: criteria they satisfy came back `unsatisfied`, failing the
   story and pointing the repair at other prompts. Those verdicts now degrade to
   `unclear` naming the file, and no readable file at all raises.

6. The terminal headline judged FAIL before UNEVALUATED while the caller judged
   the reverse, so one run could print FAIL and emit UNKNOWN in the v1 document.

7. `run_user_story_fix(legacy_detect=True)` planned with the legacy detector but
   re-validated with the criteria gate.

Not addressed: model-supplied `prompt_name` is resolved against every prompt in
the tree rather than the story's linked set, so a wrong guess could edit an
unlinked prompt. The correct fix is to scope the repair to the linked prompts
the gate evaluates -- a behaviour change worth doing deliberately rather than
folded into this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine swap is what fixes promptdriven#2389: story validation asked a question with no
passing answer, and now asks one with three bounded answers that requires
evidence. Where verification *belongs* in the command tree is a separate design
opinion, and bundling it made this PR carry a new command, a new front door, its
own tests, an ownership rule and a digest re-pin for no gain to the fix.

Removed: the `story verify` subcommand, its test file, its sync-ownership rule,
and the digest re-pin (which returns exactly to b23599ec, the value pinned for
the story_criteria test rule). Docs revert to the `pdd detect --stories`
spelling while keeping the substantive additions -- the four contract sections,
the citation requirement and the unclear/UNKNOWN semantics all describe the
engine, which stays.

`pdd detect --stories` keeps the sound engine and still exits 1 on a story
FAIL. `detect_change` remains untouched for its two non-story callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@agarwal-ishaan
agarwal-ishaan force-pushed the fix/issue-2389-story-criteria-gate branch from 9a76c6e to 3ba082f Compare August 14, 2026 01:06

@gltanaka gltanaka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Follow-up review of b09c7f82: the update correctly fixes the evaluator-error scope narrowing, the merge conflict, and current profile reconciliation. The core per-criterion design remains worthwhile, but this head is still not safe or repository-accepted.

  1. P1 — generated prompt and architecture artifacts disagree, failing both required CI jobs. architecture.json:10141-10146 declares story_criteria_python.prompt as a dependency of user_story_tests_python.prompt, but pdd/prompts/user_story_tests_python.prompt:26-29 has no matching <pdd-dependency>story_criteria_python.prompt</pdd-dependency>. I reproduced this with pdd checkup --validate-arch-includes. Unit job 94651404194 and Public CLI Regression job 94651404220 fail on this exact mismatch. Add the dependency and re-rotate the resulting prompt/profile hashes.

  2. P1 — the rollout ownership bridge is pinned to stale bytes. Commit b09c7f82 removed preauthorize_absent, so the current .pdd/sync-ownership.json SHA256 is 1402941794d1b593847eb8c277c641f40ce159c1cde3012d6880400be3763c69. pdd/sync_core/manifest.py:302-305 authorizes only the earlier b23599ec... story-criteria head. Consequently tests/test_sync_core_pdd_rollout_policy.py:2364 fails because all eight repaired metadata rules disappear. Authorize the exact new current pair while retaining required frozen historical pairs.

  3. P1 — historical rollout tests use the current inventory count. EXPECTED_MANAGED_UNITS is now 471, but the pinned #1875 and replay histories contain 469. Assertions at tests/test_sync_core_pdd_rollout_policy.py:2205 and :2225 fail. Keep 471 for current-head checks and use a frozen 469 constant for those historical states.

  4. P1 — merging #2390 alone still activates a known false-PASS default. pdd/commands/analysis.py:664 and pdd/user_story_tests.py:1816 still default to strength 0.2. The contributor's live #2394 evidence shows a genuinely regressed prompt set passing 5/5 at that exact default with confident satisfied verdicts, so UNKNOWN handling does not protect this path. PR #2398 is now CI-green but remains open/unapproved and overlaps this prompt/profile transition. Validate an ordered stacked/combined head and land the equivalent strength correction with this gate; #2390 is unsafe standalone.

Validation on this head:

  • 228 passed: story criteria, user-story orchestration, stable result, and analysis CLI.
  • 313 passed: change, drift, and agentic-change callers.
  • 81 verification-profile tests and 34 manifest tests passed independently.
  • Three exact rollout-policy failures reproduced as described.
  • git diff --check clean; merge-tree clean; GitHub reports MERGEABLE.

The contributor's E2E work is useful, but it is not sufficient for merge while its own default-tier negative control false-passes and required repository gates are red.



- Declare story_criteria_python.prompt as a dependency of
  user_story_tests_python.prompt (matching architecture.json) and rotate the
  resulting prompt/profile requirement hashes so pdd checkup
  --validate-arch-includes passes.
- Re-pin the sync-rollout ownership repair bridge to the current
  .pdd/sync-ownership.json bytes (after b09c7f8 dropped
  preauthorize_absent), while keeping the earlier story-criteria pair as
  frozen protected history.
- Freeze EXPECTED_MANAGED_UNITS at 469 for the promptdriven#1875 protected-history and
  replay tests, which predate the story-criteria unit registrations that
  bumped the current count to 471.
- Use DEFAULT_STRENGTH instead of a hardcoded 0.2 for story validation's
  strength default (pdd/commands/analysis.py and the five story-related
  signatures in pdd/user_story_tests.py), closing the known false-PASS
  default path documented in promptdriven#2394/promptdriven#2398.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@gltanaka gltanaka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Follow-up review of 03a3adce: this update correctly fixes the prompt/architecture dependency mismatch and the frozen 469-unit historical assertions, and it repairs the ownership bridge for the isolated PR head. It also raises several direct helper defaults. The PR is still not safe or repository-accepted.

  1. P1 — the actual protected-base rollout remains invalid. Building the production manifest for 0faf7be4..HEAD reports tests/test_story_criteria.py: tracked path has no ownership rule; the candidate-only ordinary row in .pdd/sync-ownership.json cannot grant protected absent-path authority. Loading profiles then raises candidate active requirement transition rules are ambiguous. The policy has two active user_story_tests_python.prompt rows around .pdd/verification-profile-rotations.json:650-670; the prior 1c467... -> 4a7ccc... row was not retired in favor of the new 4a7ccc... -> a12545... row. The focused rollout set is 4 passed / 1 failed at tests/test_sync_core_pdd_rollout_policy.py:2706. Install ownership and transition authority through the required protected Phase A, consume it in a later Phase B, and add the real protected-base-to-candidate loader test.

  2. P1 — the known strength-0.2 false-PASS path is only partially fixed. pdd/commands/fix.py:216 still forces 0.2 into initial and post-fix story validation. Strict agentic-change validation still receives 0.2 from pdd/agentic_change_orchestrator.py:3767 and forwards it to run_user_story_tests at line 1549. pdd/commands/generate.py:863,906 and pdd/prompts/agentic_change_orchestrator_python.prompt:509 also preserve 0.2. Current generate tests explicitly assert that stale default, while user_story_tests_python.prompt does not specify the new DEFAULT_STRENGTH contract. Update every validation caller and the corresponding prompt/architecture sources; otherwise the contributor's documented 5/5 false PASS remains reachable.

  3. P1 — current-main integration is still conflicting and the merged ownership digest is stale. git merge-tree --write-tree origin/main HEAD conflicts in .pdd/verification-profile-rotations.json, pdd/sync_core/verification.py, and tests/test_sync_core_pdd_rollout_policy.py. The auto-merged ownership bytes hash to 1e26e347..., while the merged manifest authorizes main's 2b4cee..., prior b23599..., and branch-head 140294...—not the actual merged result. Rebase, preserve current-main rollout semantics, and pin/test the exact resolved bytes.

Validation on this head:

  • Architecture include validation passed.
  • 228 story/analysis tests passed.
  • 342 affected caller tests passed.
  • Focused rollout: 4 passed, 1 failed as above.
  • Actual 0faf7be4..HEAD production loader reproduced unowned test + ambiguous active transition.
  • git diff --check clean; worktree clean.
  • GitHub reports CONFLICTING and Changes Requested; required CI has not run on this head.

#2398 now contains more complete caller/prompt propagation, but remains Changes Requested with a red unit job; it is not a validated substitute.

agarwal-ishaan and others added 3 commits August 15, 2026 23:16
…criteria-gate

Resolve the story-criteria rotation onto main's post-promptdriven#2380 profile head:
the user-story requirement transition now runs 5b1353 -> 103527 and retires
main's consumed 1c4670 -> 5b1353 row, so exactly one active row remains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `tests/test_story_criteria.py` regains `preauthorize_absent`, without which
  the protected base cannot own a path this branch adds; re-pin the ownership
  bridge to the resulting bytes.
- `pdd fix`, `pdd test` and the agentic change orchestrator stop forcing
  strength 0.2 into story validation; they now default to DEFAULT_STRENGTH.
- State the DEFAULT_STRENGTH contract in user_story_tests_python.prompt and
  re-mint the profile/rotation chain it heads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…essor

PR promptdriven#2374 already established the single-change shape for this: retire the
consumed successor row and consume its replacement in the same hash-bound
transition. Do the same for the user-story row.

- `.pdd/verification-profile-rotations.json` retires main's consumed
  `1c4670 -> 5b1353` row in favour of `5b1353 -> 51fd30`, so exactly one
  active row remains for the identity.
- `_STORY_CRITERIA_ROTATION_POLICY_BYTES` / `_PROFILE_BYTES` bind the pair to
  exact whole-file bytes, and `exact_pr2390_story_criteria_successor` lapses on
  any other edit.
- Register the two new story_criteria prompts in `_BOOTSTRAP_PROFILE_ADDITIONS`
  and `tests/test_story_criteria.py` in `_BOOTSTRAP_HUMAN_OWNERSHIP`, so the
  protected base can own paths this branch adds.
- Raise the story-validation strength defaults in `pdd fix`, `pdd test` and the
  agentic change orchestrator to DEFAULT_STRENGTH (promptdriven#2394), state that contract
  in user_story_tests_python.prompt, and update the two tests that asserted the
  stale 0.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@agarwal-ishaan

Copy link
Copy Markdown
Collaborator Author

Addressed on cf516d1e. All three P1 items, plus the merge conflict.

1. Protected-base rollout — fixed, and structured on the #2374 precedent

The root cause was that the retirement and the prompt change were unauthorized together, not that they needed splitting: _validate_retirement_managed_prompt_bytes rejects any candidate that carries a new retirement alongside managed prompt drift.

PR #2374 already solved exactly this shape for the code generator — exact_pr2374_conformance_successor retires the consumed successor row and consumes its replacement in one hash-bound transition. This branch now follows it:

  • One active row. .pdd/verification-profile-rotations.json retires main's consumed 1c4670… -> 5b1353… row in favour of 5b1353… -> 51fd30…, so _active_requirement_transition_authorizations sees exactly one row for user_story_tests_python.prompt. The ambiguity you found is gone by construction, not by suppression.
  • Bound to exact bytes. _STORY_CRITERIA_ROTATION_POLICY_BYTES and _STORY_CRITERIA_PROFILE_BYTES are whole-file digests of the rotations and profiles policies. exact_pr2390_story_criteria_successor lapses on any other edit to either file, so it names one reviewed transition and grants no reusable authority.
  • Absent-path ownership. tests/test_story_criteria.py was the unowned path because ownership is read from the protected base, where both the file and its rule are absent. The candidate row stays ordinary; the bridge is a _BOOTSTRAP_HUMAN_OWNERSHIP entry, the same repository-bound one-shot Split code_generator_main into pdd/conformance/ (six gate units) #2374 used for pdd/conformance/__init__.py. The two new prompts get _BOOTSTRAP_PROFILE_ADDITIONS entries pinned to the exact policy bytes.

The real protected-base-to-candidate load you asked for now passes:

build_unit_manifest(root, base_ref="origin/main", head_ref="HEAD")
  managed: 477   invalid: ()   unaccounted: []
load_verification_profiles(root, manifest)
  profiles: 477  coverage: 1.0  invalid: ()

test_conformance_split_profiles_load_from_actual_merge_base exercises this same range in the suite. 0faf7be4 is no longer the protected base — current main is, and it loads clean.

2. Strength 0.2 — every Python caller raised

grep for a hardcoded story-validation strength in pdd/commands/*.py, pdd/agentic_change_orchestrator.py and pdd/user_story_tests.py now returns nothing:

site before after
pdd/commands/fix.py:216 0.2 DEFAULT_STRENGTH
pdd/commands/generate.py:863 0.2 DEFAULT_STRENGTH
pdd/commands/generate.py:906 0.2 DEFAULT_STRENGTH
pdd/agentic_change_orchestrator.py:3794 0.2 DEFAULT_STRENGTH
pdd/commands/analysis.py:664, pdd/user_story_tests.py ×5 already DEFAULT_STRENGTH

agentic_change_orchestrator.py:3794 was the one that reached strict validation: it forwards into run_user_story_tests at line 1549. The two tests/commands/test_generate.py cases that asserted the stale 0.2 now assert DEFAULT_STRENGTH.

user_story_tests_python.prompt now carries the contract explicitly, so a regeneration cannot reintroduce the low tier:

Every public entry point that reaches story validation MUST default strength to the package-level DEFAULT_STRENGTH, never to a hardcoded low tier.

One site deliberately left alone: pdd/prompts/agentic_change_orchestrator_python.prompt. PR #2398 already rotates that exact prompt, and two open PRs rotating the same contract would produce two competing transition rows for one identity — the failure this PR just removed. Its Python caller is corrected here, so the reachable false-PASS path is closed; the spec text lands with #2398.

3. Merge conflict — resolved

origin/main merged in. GitHub now reports MERGEABLE; git merge-tree --write-tree origin/main HEAD is clean.

  • pdd/sync_core/verification.py: took main's chain through _STORY_PROMPT_PHASE_B_* verbatim and re-chained the story-criteria constants onto it, rather than the stale zsh head they were bound to.
  • .pdd/verification-profiles.json: neither side's requirement id survives a merge that touches the prompt from both sides. Rebuilt from the merged prompt: 51fd30da….
  • EXPECTED_MANAGED_UNITS is 477 (main's 475 + the two story-criteria units); PDD_1875_EXPECTED_MANAGED_UNITS stays frozen at 469 for the historical refs.
  • Ownership: the merged bytes are 88eb6f8c…. _SYNC_ROLLOUT_REPAIR_OWNERSHIP_BYTES[1] is re-pinned to that, and main's 2b4cee2c… is retained as _SYNC_ROLLOUT_REPAIR_MAIN_OWNERSHIP_BYTES so the repair keeps authority on the protected base as well as this head.

Validation on cf516d1e

  • 258 passed, 0 failedtest_sync_core_pdd_rollout_policy.py + test_sync_core_verification_profiles.py + test_sync_core_manifest.py. Both failures from the previous head (test_pdd_protected_inventory_is_complete_and_exact, test_sync_rollout_repair_ownership_pin_tracks_the_actual_policy_file) are fixed at the source, not by loosening the assertion.
  • 670 passed — story criteria, user-story orchestration, analysis/fix/generate/modify/story CLI, detect-change, drift, and both agentic-change caller suites.
  • pdd checkup --validate-arch-includes: No architecture / <include> mismatches found.
  • git diff --check clean; worktree clean; git merge-tree clean.

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.

pdd detect --stories verdict depends on model strength: pass criterion is 'empty changes list' from an open-ended prompt

2 participants