Summary
The score-preview gate blockedByFor emits a duplicate_risk reducer when input.duplicateRiskCount > 0:
// src/scoring/preview.ts:589-597
...(nonNegative(input.duplicateRiskCount) > 0
? [
{
code: "duplicate_risk" as const,
severity: "reducer" as const,
detail: `${nonNegative(input.duplicateRiskCount)} duplicate-risk issue(s) or PR(s) detected; verify there is no conflicting work before proceeding.`,
},
]
: []),
But no production code ever sets duplicateRiskCount. A grep across src/ returns only the type declaration (preview.ts:32) and this consumer (preview.ts:589/594) — every producer omits it:
buildLocalScoreInput (src/signals/local-branch.ts) threads every other observed-PR signal into the score input, but not duplicate-risk:
observedApprovedPrCount: args.observedPullRequestScenarios.approvedOrMergeable,
observedStalePrCount: args.observedPullRequestScenarios.stale, // -> drives the stale_work reducer
observedClosedPrCount: args.observedPullRequestScenarios.closed,
observedDraftPrCount: args.observedPullRequestScenarios.draft,
observedBlockedPrCount: args.observedPullRequestScenarios.blocked,
observedMaintainerPrCount: args.observedPullRequestScenarios.maintainerLane,
// duplicateRiskCount: never set
buildRepoRewardRisk (src/signals/reward-risk.ts) builds its commonPreviewInput for buildScorePreview without duplicateRiskCount.
- The public API
scorePreviewSchema (src/api/routes.ts) and the MCP score-preview input don't declare a duplicateRiskCount field, so an external caller can't supply it either.
So the duplicate_risk reducer is dead code on every path. Only test/unit/scenario-blockers.test.ts exercises it by passing duplicateRiskCount directly to buildScorePreview.
Why this is wrong
The sibling reducer right above it, stale_work (preview.ts:580), is fed — buildLocalScoreInput sets observedStalePrCount from the observed-PR scenarios. The two were added together (the merged "duplicate and stale-work scenario blockers" feature, issue #289 / PR #346), but only stale-work got wired to a producer. The asymmetry is the bug: stale work surfaces a private reducer; duplicate work never does.
The duplicate-risk data the reducer needs is already computed on both producing paths:
- Local-branch path:
buildPreflightResult's collisions (a CollisionReport of overlap/WIP clusters with per-cluster risk) is computed and even rendered into the contributor-facing packet's "Overlap/WIP Check" (local-branch.ts:1055). It is simply never converted into duplicateRiskCount for the score input.
- Reward-risk path:
buildRepoRewardRisk already computes collisions = buildCollisionReport(...) and uses collisions.summary.highRiskCount / clusterCount for riskPenalty (reward-risk.ts:216), but never passes that count into its buildScorePreview input.
So the system detects the conflicting work and warns about it in the public Overlap/WIP packet, yet the private scoreability surface silently reports no duplicate-risk reducer.
Failure mode (concrete example)
A contributor runs the MCP/agent preflight_branch / explain_branch_blockers flow on a branch whose linked issue (or changed paths) collides with another open PR/issue, so buildCollisionReport returns a high-risk cluster and the public packet warns "Possible overlap or WIP (high)…".
- Current:
scorePreview.blockedBy and warnings contain no duplicate_risk entry, and the private "explain blockers" action (agent-orchestrator.ts, which surfaces scenarioScorePreview.blockedBy) lists stale/credibility/open-PR reducers but is silent on the duplicate collision.
- Correct: a
duplicate_risk reducer with the cluster count appears in blockedBy / warnings and in the contributor-facing blocker explanation, matching the public Overlap/WIP warning.
Downstream impact
The private decision/blocker surface under-reports conflicting work: a miner is privately told the branch has no extra duplicate risk while another open PR already targets the same issue — exactly the collision the system computes and is meant to flag — increasing wasted duplicate submissions and maintainer review churn. The feature was clearly intended (full type + gate + test coverage) but is unreachable in production.
Steps to reproduce
- Build a local-branch analysis (or a reward-risk preview) for a branch/repo where
buildCollisionReport returns a high-risk overlap cluster.
- Inspect the resulting
ScorePreviewResult.blockedBy / warnings.
- Observe there is no
duplicate_risk reducer, even though the public Overlap/WIP packet flags the collision and the reward-risk path already has collisions.summary.highRiskCount > 0.
Expected behavior
When duplicate/overlap risk is detected (a high-risk collision cluster), the score preview surfaces the duplicate_risk reducer in blockedBy / warnings, consistent with the public Overlap/WIP warning and with how the sibling stale_work reducer is wired.
Actual behavior
duplicateRiskCount is never set by any producer, so the duplicate_risk reducer never fires; only a direct unit test exercises the consumer.
Suggested fix
- Thread a duplicate-risk count into the score input on the producing paths:
- In
buildLocalScoreInput (local-branch.ts), derive duplicateRiskCount from the already-available preflight.collisions (e.g. the count of high-risk overlap clusters relevant to the branch) and add it to the returned ScorePreviewInput. This requires threading preflight.collisions into buildLocalScoreInput (a new param) since it currently doesn't receive it.
- In
buildRepoRewardRisk (reward-risk.ts), add duplicateRiskCount: collisions.summary.highRiskCount (or clusterCount) to commonPreviewInput.
- Optionally accept
duplicateRiskCount on the public scorePreviewSchema (routes.ts) and the MCP score-preview input so external callers can supply it too, matching the other observed-PR inputs.
- Add fail-on-revert coverage: a local-branch analysis / reward-risk preview with a high-risk collision cluster yields a
duplicate_risk reducer in blockedBy, not just the direct-input unit test.
Summary
The score-preview gate
blockedByForemits aduplicate_riskreducer wheninput.duplicateRiskCount > 0:But no production code ever sets
duplicateRiskCount. A grep acrosssrc/returns only the type declaration (preview.ts:32) and this consumer (preview.ts:589/594) — every producer omits it:buildLocalScoreInput(src/signals/local-branch.ts) threads every other observed-PR signal into the score input, but not duplicate-risk:buildRepoRewardRisk(src/signals/reward-risk.ts) builds itscommonPreviewInputforbuildScorePreviewwithoutduplicateRiskCount.scorePreviewSchema(src/api/routes.ts) and the MCP score-preview input don't declare aduplicateRiskCountfield, so an external caller can't supply it either.So the
duplicate_riskreducer is dead code on every path. Onlytest/unit/scenario-blockers.test.tsexercises it by passingduplicateRiskCountdirectly tobuildScorePreview.Why this is wrong
The sibling reducer right above it,
stale_work(preview.ts:580), is fed —buildLocalScoreInputsetsobservedStalePrCountfrom the observed-PR scenarios. The two were added together (the merged "duplicate and stale-work scenario blockers" feature, issue #289 / PR #346), but only stale-work got wired to a producer. The asymmetry is the bug: stale work surfaces a private reducer; duplicate work never does.The duplicate-risk data the reducer needs is already computed on both producing paths:
buildPreflightResult'scollisions(aCollisionReportof overlap/WIP clusters with per-clusterrisk) is computed and even rendered into the contributor-facing packet's "Overlap/WIP Check" (local-branch.ts:1055). It is simply never converted intoduplicateRiskCountfor the score input.buildRepoRewardRiskalready computescollisions = buildCollisionReport(...)and usescollisions.summary.highRiskCount/clusterCountforriskPenalty(reward-risk.ts:216), but never passes that count into itsbuildScorePreviewinput.So the system detects the conflicting work and warns about it in the public Overlap/WIP packet, yet the private scoreability surface silently reports no duplicate-risk reducer.
Failure mode (concrete example)
A contributor runs the MCP/agent
preflight_branch/explain_branch_blockersflow on a branch whose linked issue (or changed paths) collides with another open PR/issue, sobuildCollisionReportreturns a high-risk cluster and the public packet warns "Possible overlap or WIP (high)…".scorePreview.blockedByandwarningscontain noduplicate_riskentry, and the private "explain blockers" action (agent-orchestrator.ts, which surfacesscenarioScorePreview.blockedBy) lists stale/credibility/open-PR reducers but is silent on the duplicate collision.duplicate_riskreducer with the cluster count appears inblockedBy/warningsand in the contributor-facing blocker explanation, matching the public Overlap/WIP warning.Downstream impact
The private decision/blocker surface under-reports conflicting work: a miner is privately told the branch has no extra duplicate risk while another open PR already targets the same issue — exactly the collision the system computes and is meant to flag — increasing wasted duplicate submissions and maintainer review churn. The feature was clearly intended (full type + gate + test coverage) but is unreachable in production.
Steps to reproduce
buildCollisionReportreturns a high-risk overlap cluster.ScorePreviewResult.blockedBy/warnings.duplicate_riskreducer, even though the public Overlap/WIP packet flags the collision and the reward-risk path already hascollisions.summary.highRiskCount > 0.Expected behavior
When duplicate/overlap risk is detected (a high-risk collision cluster), the score preview surfaces the
duplicate_riskreducer inblockedBy/warnings, consistent with the public Overlap/WIP warning and with how the siblingstale_workreducer is wired.Actual behavior
duplicateRiskCountis never set by any producer, so theduplicate_riskreducer never fires; only a direct unit test exercises the consumer.Suggested fix
buildLocalScoreInput(local-branch.ts), deriveduplicateRiskCountfrom the already-availablepreflight.collisions(e.g. the count of high-risk overlap clusters relevant to the branch) and add it to the returnedScorePreviewInput. This requires threadingpreflight.collisionsintobuildLocalScoreInput(a new param) since it currently doesn't receive it.buildRepoRewardRisk(reward-risk.ts), addduplicateRiskCount: collisions.summary.highRiskCount(orclusterCount) tocommonPreviewInput.duplicateRiskCounton the publicscorePreviewSchema(routes.ts) and the MCP score-preview input so external callers can supply it too, matching the other observed-PR inputs.duplicate_riskreducer inblockedBy, not just the direct-input unit test.