Skip to content

fix(development): restore phase-strict gating silently disabled by an option rename - #81

Merged
bautrey merged 4 commits into
mainfrom
fix/phase-gate-option-rename-and-cli-error
Aug 4, 2026
Merged

fix(development): restore phase-strict gating silently disabled by an option rename#81
bautrey merged 4 commits into
mainfrom
fix/phase-gate-option-rename-and-cli-error

Conversation

@bautrey

@bautrey bautrey commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Why

main has been red since June with 3 failing tests. They are unrelated to any open PR — including #80, which inherits them and adds zero new failures of its own. This clears them so #80 (and anything else) can merge against a green baseline.

The real bug

7b73b7a renamed selectNextTasks' option from prFormat to stacked in phase-tracker.js. The call site in complete-beads-planner.js:338 was missed and still passed prFormat.

The result: options.stacked === true is never true, so selectNextTasks takes the unfiltered branch and phase-strict gating does nothing. A later-phase task can jump ahead of an incomplete earlier phase — exactly what the gate exists to prevent.

This is silent by construction. The planner still returns a plausible selection; it is just the wrong one. implement-trd-beads has been running without phase enforcement since that rename.

The planner keeps prFormat as its own public option name (it comes from the CLI's --pr-format). Only the call into phase-tracker moves to the new key.

Mutation-verified: reverting that single word fails 2 tests.

Second fix

trd-cli invoked with no subcommand emitted only a usage string, so a caller matching on the cause could not distinguish an invocation error from a help request. Now leads with Missing subcommand. and keeps the usage after it.

Verification

CI=true npm test   →  exit 0, 0 failing suites, 722 passed
                      (main: 719 passed / 3 failed)
npm run generate   →  exit 0, no artifact drift

Install note for anyone reproducing: npm ci alone fails because five workspace packages pin @fortium/ensemble-development@^4.0.0 against a workspace at 5.8.0. CI uses npm ci --legacy-peer-deps, which works. That mismatch is left alone here — separate concern, separate PR.

Files

Two, both one-line changes plus explanatory comments.

…ubcommand error

Two pre-existing failures on main. Both have been red since June and neither is
related to any open PR.

PHASE-STRICT GATING WAS SILENTLY DISABLED. Commit 7b73b7a renamed
selectNextTasks' option from `prFormat` to `stacked` in phase-tracker, but the
call site in complete-beads-planner.js still passed `prFormat`. That made
`options.stacked === true` never true, so selectNextTasks took the unfiltered
branch and the phase boundary stopped being enforced -- a later-phase task could
jump ahead of an incomplete earlier phase, which is the precise bug the gate was
written to prevent. Nothing surfaced it because the planner still returned a
plausible-looking selection; it was just the wrong one.

The planner keeps `prFormat` as its own public option name (it comes from the
CLI's --pr-format). Only the call INTO phase-tracker moves to the new key.

Mutation-verified: reverting that one word fails 2 tests.

MISSING-SUBCOMMAND ERROR WAS INDISTINGUISHABLE FROM HELP. trd-cli emitted only
a usage string when invoked with no subcommand, so a caller matching on the
cause could not tell an invocation error from a help request. Now leads with
"Missing subcommand." and keeps the usage after it.

CI=true npm test: 0 failing suites, 722 passed in the affected workspace (was
719 passed / 3 failed). npm run generate is clean -- no artifact drift.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 17 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3bf8d396-bd9b-400c-b401-cd8d6920a1d8

📥 Commits

Reviewing files that changed from the base of the PR and between 5edaaa2 and 34498b6.

📒 Files selected for processing (5)
  • packages/development/lib/complete-beads-planner.js
  • packages/development/lib/trd-cli.js
  • packages/development/skills/complete-beads/SKILL.md
  • packages/development/tests/complete-beads-planner.test.js
  • packages/pi/skills/complete-beads/SKILL.md

Comment @coderabbitai help to get the list of available commands.

@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review

Verified this against the source, not just the PR description — the claims hold up.

Root-cause fix is correct. phase-tracker.js (selectNextTasks) reads options.stacked === true (line 162). The other call site in trd-cli.js:272 already passes { stacked, ... } correctly — so complete-beads-planner.js:338 was indeed the one straggler still passing the pre-rename prFormat key, meaning phase-strict gating silently no-oped. Good catch, and a scary one given it's a silent correctness bug (planner still returns a plausible-looking selection).

Tests actually cover the regression, not just assert current behavior — complete-beads-planner.test.js:217 and :236 exercise { prFormat: true } through applyPhaseFilterselectNextTasks and check phase-gate deferrals; both would fail against the buggy { prFormat: true } call site since stacked would never be truthy. Consistent with the "reverting one word fails 2 tests" mutation claim in the description.

Naming is a little confusing but intentional and explained. The planner's public option is still prFormat (from the CLI's --pr-format), while the internal call into phase-tracker now correctly uses stacked. The added comment (lines 338-345) is well-placed and explains why the two names diverge — without it this would look like a naming inconsistency to introduce, not fix.

CLI error fix (trd-cli.js) is a straightforward, low-risk clarity improvement — Missing subcommand. now prefixes the usage string so callers can distinguish invocation error from help text. Covered by trd-cli.test.js:340 (toMatch(/Missing subcommand/)).

Minor, non-blocking observation: packages/full/lib/trd-cli.js:387 already has its own Missing subcommand.-prefixed message (pre-existing, not touched by this PR) but with a shorter subcommand list — missing choices-read|choices-write that both variants in packages/development/lib/trd-cli.js include. Looks like packages/full is a bundled/duplicated copy that's already drifted from packages/development. Not this PR's concern (it doesn't touch packages/full), but worth a follow-up if that bundle is supposed to stay in sync.

Scope discipline: exactly the two one-line changes described, both with comments explaining the why (per this repo's comment convention — non-obvious constraint/history, not restating the diff). No unrelated cleanup bundled in.

Overall: a well-targeted, well-verified fix for a nasty silent-failure bug, with tests that actually pin the regression down. LGTM.

…er it

Review caught that restoring the gate would ship a regression, and it is the
same silent-failure class the original fix removes -- just relocated.

MISSING PHASE MAP BECAME A TOTAL STALL. complete-beads-cli turns an absent
phase file into `{}` (`phaseTaskIdsJson || {}`). With the gate dead that was
harmless. With it live, currentPhase() returns null and EVERY ready bead is
deferred as 'phase-gate' -- reported to the operator as "waiting on an earlier
phase", exit 0, nothing scheduled. Reproduced before the fix: empty map gave
selected=[] deferred=[t1,t2]; populated map gave selected=[t1].

Step 7's own call-site comment already said "if TRD phase metadata present" and
nothing enforced it. Now it does: no phase metadata means no boundaries to
enforce, so the ids pass through -- and it warns on stderr, because a TRD that
should have phases and silently lost its map is otherwise indistinguishable
from one that never had phases, and that difference decides whether the run is
correct.

UNPARSEABLE TITLES WERE MISLABELLED. extractTaskId falls back to the bead id,
which by construction never appears in phaseTaskIds, so any bead whose title
lacks its [trd:<slug>:task:<id>] marker can never be selected while the gate is
live. Discarding it is deliberate -- phase-tracker documents that an id with no
phase mapping cannot be proven to belong to the current phase -- but reporting
'phase-gate' sends the operator hunting an earlier phase when the real cause is
a malformed title. Now deferred as 'unparseable-task-id'.

Both guards are mutation-verified: removing either fails exactly one test.

CI=true npm test: 0 failing suites. npm run generate: clean.
@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review

Verified against the code (not just the PR description) — both fixes check out.

Core fix is real and correctly scoped

  • phase-tracker.js's selectNextTasks reads options.stacked (packages/development/lib/phase-tracker.js:162), and pr-strategy.js already calls it correctly with { stacked: true, ... }. complete-beads-planner.js was the one straggler still passing { prFormat: true, ... } from the pre-rename API — confirmed via git diff, this is a real dead option that silently defeated phase-strict gating.
  • The claim about trd-cli.js's "no subcommand" message is independently corroborated: tests/trd-cli.test.js:337-341 already asserts /Missing subcommand/ and was not touched by this PR's diff, meaning it was failing on main before this change and is fixed by it. That's solid evidence the fix is real, not just self-serving new tests.

The two bundled defensive fixes (not mentioned in the "Why" summary title, but present in the diff)

  1. Empty phase map falls through instead of stalling (complete-beads-planner.js:332-340) — good catch. Re-arming the gate without this would turn a missing/failed phase-file load into a silent 100%-stall (deferReason: 'phase-gate' on every bead, exit 0), which is arguably worse than the bug being fixed. The stderr warning is a reasonable compromise given applyPhaseFilter has no other output channel.
  2. unparseable-task-id vs phase-gate deferral reason (complete-beads-planner.js:380-388) — correct and well-justified: a bead title missing its [trd:...:task:...] marker falls back to the bead id, which by construction can never be in phaseTaskIds, so mislabeling it phase-gate would send an operator chasing the wrong root cause.

Both are scope creep relative to the stated "one word" fix, but they're small, directly adjacent to the code being touched, and each has a dedicated regression test with a clear comment explaining the failure mode it guards against. I'd call this acceptable, not something to push back on.

Minor / non-blocking

  • packages/development/skills/complete-beads/SKILL.md:121 enumerates deferral reasons as "phase-gate, file-claim-conflict, slot-cap" — now stale, missing the new unparseable-task-id. Doesn't affect behavior since consumers treat deferReason opaquely (SKILL.md:38 just forwards it as reason), but worth a follow-up doc touch.
  • The empty-phase-map guard checks Object.keys(phaseTaskIds).length === 0, but doesn't cover the case where phase keys exist with all-empty arrays (e.g. {1: [], 2: []}) — currentPhase() would return null there too (all phases vacuously complete) and everything gets deferred as phase-gate again. This is a pre-existing edge case, arguably correct (no tasks is different from no map), and out of scope for this PR — just flagging for awareness.
  • Fix targets packages/development/lib/trd-cli.js only. There's a separately-maintained fork at packages/full/lib/trd-cli.js that already independently has the "Missing subcommand." prefix (it appears to have diverged before this bug was introduced there), so no action needed — just noting I checked for drift and found none caused by this PR.

Test coverage

The two new tests in complete-beads-planner.test.js correctly exercise both defensive fixes, with argument order matching planDispatch's signature and assertions on both the returned shape and the stderr warning content. Good.

Security / performance

No concerns — this is pure selection logic with no I/O, network, or shell calls, consistent with the module's documented "NO side effects" contract.

Overall: Well-diagnosed, well-tested fix for a real silent-failure bug. The two bundled hardening changes are justified by the same failure mode being reintroduced by re-arming the gate, and are tested. Nothing blocking.

…closed

Pass 2 caught that my own guard failed open on the one condition it was written
to detect, and that it counted the wrong thing. Both confirmed by execution.

WRONG DIRECTION. trd-parser sets prFormat=true only when the TRD has `PR N:`
headings (`if (sawPR) { prFormat = true; ... }`), and a PR heading always yields
at least one phase. So prFormat with no phase task ids is NOT "a TRD without
phases" -- it can only mean the phase map failed to load, which is exactly how
complete-beads-cli's `phaseTaskIdsJson || {}` delivers a missing or unreadable
--phase-task-ids file. Passing the ids through with a warning silently
dispatched later-phase work across a real boundary: the failure this entire fix
exists to remove, reintroduced by its own guard. Now throws, which is how
planDispatch already signals unusable input -- and which removes the
library-writes-to-stderr side effect the warning introduced.

WRONG COUNT. The guard counted phase KEYS. buildPhaseTaskIds keeps empty phases
on the parse path, so {"1":[],"2":[]} has two keys and zero task ids -- it
skipped the guard, currentPhase() returned null, and every ready bead was
deferred 'phase-gate'. The identical total stall, still reachable through a
shape the guard was supposed to cover. Now counts task ids.

Also documents 'unparseable-task-id' in the deferral-reason list in
skills/complete-beads/SKILL.md and its generated Pi copy, which still read
(phase-gate, file-claim-conflict, slot-cap).

Mutation-verified both ways: counting keys instead of task ids fails 1 test;
passing through instead of throwing fails 3. A no-prFormat case asserts the
gate still does not fire when phase strictness was never requested.

36/36 in that file. CI=true npm test: 0 failing suites. npm run generate: clean.
@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review

Reviewed the diff (complete-beads-planner.js, trd-cli.js, two SKILL.md docs, tests). Solid, well-scoped fix with unusually thorough commit messages explaining the "why." One real issue found by tracing the new guard logic.

Bug: dead/unreachable code in applyPhaseFilter

packages/development/lib/complete-beads-planner.js:338-356:

const phaseIdCount = phaseTaskIds
  ? Object.values(phaseTaskIds).reduce((n, ids) => n + (Array.isArray(ids) ? ids.length : 0), 0)
  : 0;
if (phaseIdCount === 0) {
  throw new Error(/* fail closed */);
}

// Extract task IDs from all closed beads for accurate currentPhase detection
const phaseCount = phaseTaskIds ? Object.keys(phaseTaskIds).length : 0;
if (phaseCount === 0) {
  process.stderr.write('WARN ... phase-strict gating does not apply and all ready beads pass through...\n');
  return { passed: orderedIds, deferred: [] };
}

The second block (phaseCount === 0 → warn and pass through) can never execute. phaseIdCount is the sum of array lengths across all values of phaseTaskIds; if that sum is > 0, at least one key must map to a non-empty array, which means Object.keys(phaseTaskIds).length >= 1. So whenever control reaches line 348, phaseCount is already guaranteed to be >= 1. The only way to reach phaseCount === 0 (empty map, undefined, or {1: [], 2: []}) is also the only way to make phaseIdCount === 0, and that case already threw at line 338.

This looks like a leftover from the iteration described in the second commit message ("Review caught that restoring the gate would ship a regression... An earlier version of this guard passed the ids through with a warning") — the old fail-open warning branch should have been deleted when the fail-closed throw was added above it, but it's still here. It's not functionally harmful (it's simply never reached — no test exercises it, and indeed no test can), but it's misleading: a reader skimming this function would conclude there's a fail-open fallback for "no phase metadata," when in fact the function unconditionally throws in that case. The // Extract task IDs from all closed beads... comment is also now misplaced — it originally documented the closedSet line right below, but the dead block was inserted between the comment and its target.

Suggest deleting lines 347-356 (the phaseCount/warn block) entirely, since the phaseIdCount guard above already fully subsumes it.

Everything else looks good

  • The actual bug fix (prFormatstacked at the selectNextTasks call site) is correct and well-verified — matches phase-tracker.js's options.stacked === true check, and the mutation-testing claim ("reverting that single word fails 2 tests") is easy to believe given the new test.each coverage for the fail-closed guard and the "unparseable bead title" test.
  • Fail-closed design is the right call for a phase-gate: silently passing later-phase work through on a load failure is exactly the bug class this PR is fixing, so throwing loudly instead is the safer failure mode.
  • unparseable-task-id vs phase-gate distinction is a nice bit of care — it stops an operator from chasing a phantom "waiting on earlier phase" when the real cause is a missing [trd:<slug>:task:<id>] marker in the bead title. Both SKILL.md copies (packages/development and packages/pi) were updated consistently to document the new defer reason.
  • trd-cli.js fix is minimal and directly tested (tests/trd-cli.test.js:340 matches on /Missing subcommand/).
  • Verified independently that prFormat is only set when trd-parser.js sees a PR N: heading (sawPRprFormat = true), which supports the comment's claim that a PR-format TRD always yields at least one phase — so the throw condition is correctly scoped and won't misfire on legitimate single-PR/non-phase TRDs (those never set prFormat: true in the first place).
  • No coverage-threshold config in this package, so the dead code won't fail CI, but worth cleaning up for readability.

Nit

The PR body says "Two files, both one-line changes plus explanatory comments" — worth double-checking that description still matches, since complete-beads-planner.js picked up a second guard/throw block (not just a one-line change) between the two commits in this PR.

@bautrey

bautrey commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…phase keys

Cleanup of my own previous commit, all three found by pass 3.

DEAD CODE THAT ENCODED THE OPPOSITE POLICY. Replacing the fail-open guard with
a throw left the old `phaseCount === 0` warn-and-pass-through block sitting
directly below it, unreachable (any counted task id implies at least one key).
It still said "gating does not apply and all ready beads pass through" — the
exact behaviour this PR removes — waiting for a future edit to re-enable it, and
it kept the library-level process.stderr.write I had claimed to remove. Deleted;
zero stderr writes remain in the file. It had also stranded the "Extract task
IDs from all closed beads" comment above the wrong code; the comment is back
with the closedSet lines it describes.

GUARD COUNTED IDS phase-tracker WOULD DISCARD. sortedPhaseNumbers drops
non-finite keys, so `{a: ['TRD-001']}` counted one id, passed the guard, and
then stalled every ready bead as 'phase-gate' — the same total stall, through
the one shape left uncovered. Now counts ids only under numeric keys.
buildPhaseTaskIds and reconstructPhaseTaskIds only emit numeric keys, so this
reaches only a hand-edited or corrupted map, which is exactly when a loud
failure beats a silent one.

Mutation-verified: counting all keys again fails the new non-numeric case.

HALT CONDITIONS. skills/complete-beads/SKILL.md documented the sibling planner
throw but not this one, so an operator hitting exit 1 had no documented mapping
to complete|blocked|failed. Added there and in the generated Pi copy.

37/37. CI=true npm test: 0 failing suites. npm run generate: clean.
@bautrey

bautrey commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review

Verified the core claim directly: trd-cli.js:272 already calls selectNextTasks(ready, phaseTaskIds, closed, { stacked, max }) with the correct key, while complete-beads-planner.js was passing { prFormat: true, ... } — so options.stacked === true was indeed never true, and phase-strict gating silently fell into the unfiltered branch. Also confirmed trd-parser.js's sawPR → prFormat = true only fires when at least one PR N: heading matched, so the new "prFormat + zero phase task ids ⇒ map failed to load" guard's premise checks out. Good root-cause diagnosis, and the mutation-verification claims are plausible from reading the tests.

Strengths

  • The commit history is a genuinely useful trail: each of the three follow-up commits catches a real bug in the prior commit's own fix (fail-open guard, wrong count denominator, dead unreachable code) — that's the kind of self-review that's easy to skip and easy to regret skipping.
  • New tests (complete-beads-planner.test.js) cover the guard's edge cases well: empty map, undefined map, phases-present-but-empty, non-numeric-key ids, and the "gate must stay off without --pr-format" negative case.
  • unparseable-task-id vs phase-gate is a real improvement — conflating "title has no [trd:...] marker" with "waiting on an earlier phase" would send an operator debugging in the wrong direction.
  • Confirmed no stderr/console writes remain in complete-beads-planner.js, matching the last commit's claim of removing the library-writes-to-stderr side effect.

Notes (non-blocking)

  1. PR description is now stale. The body says "Two, both one-line changes plus explanatory comments," but the actual diff spans 5 files and 4 commits (three of which are the reviewer finding bugs in the original fix). Worth updating the description before merge so a reviewer skimming the summary isn't misled about scope.

  2. Guard's premise has a narrow edge case. The new "phaseIdCount === 0 ⇒ throw" guard assumes prFormat === true implies at least one real task exists (since a PR N: heading always yields a phase). That's true for phases, but a technically-valid TRD with PR N: headings and literally zero task lines / zero acceptance criteria under them would also hit phaseIdCount === 0 and get the same "phase map failed to load" error, even though nothing actually failed to load — the TRD is just empty. This is a pretty degenerate case (arguably such a TRD shouldn't dispatch anything anyway), so failing closed is a defensible choice either way — just flagging that the error message ("map failed to load") would be slightly misleading for that specific input shape.

  3. Comment density. The guard's explanation in applyPhaseFilter (~20 lines of comment for a ~10-line guard) and the stacked rename comment are thorough but quite long for inline comments — the reasoning is valuable but might read better condensed, with the fuller narrative left in the commit messages (which already have it).

  4. Confirmed packages/full/lib/trd-cli.js already contains the "Missing subcommand." text independent of this PR (pre-existing, unrelated to this change) — not something this PR needs to touch, just noting it's not a gap.

Verdict

Solid, well-tested fix for a real silent-failure bug, with an unusually transparent commit trail showing the review process. Only asks: refresh the PR description to match the final diff, and optionally reconsider the guard's error message for the zero-tasks-but-not-actually-broken edge case.

@bautrey
bautrey merged commit 26ef93f into main Aug 4, 2026
8 checks passed
bautrey added a commit that referenced this pull request Aug 4, 2026
…date

Two reviewers independently found the same hole: the guard hardcoded
packages/ and never read the root manifest or the workspaces globs, then
printed an unconditional all-clear. A stale range declared at the root, or
in a workspace added outside packages/*, drifted unchecked behind a green
checkmark — the failure shape this PR exists to remove.

- Drive the scan off rootPkg.workspaces and include the root manifest
  itself. 28 manifests now scanned, up from 27.
- Exit non-zero on a workspaces pattern the expander does not understand,
  rather than silently covering fewer workspaces than the config declares.
- Skip file:/link:/workspace:/portal: ranges. semver.satisfies returns false
  for those, so they were reported as failures with advice to replace a
  range that already resolves from disk.
- Stop suggestRange throwing a bare TypeError on a manifest with no version.

Corrected the date in the docstring. The masking did not start in June 2026:
0f44318 added --legacy-peer-deps on 2025-12-14, the same day 218daed moved
packages/development to 5.0.0 and broke every ^4.0.0 range. The flag went in
as the fix for that breakage and held for nearly eight months. It survived
because it never looked like a regression, it looked like CI being repaired.

Merged now-green main (26ef93f, #81), which clears the 3 inherited test
failures this branch was carrying.

Negative-tested: a stale range in the root package.json, an unsupported
workspaces glob, and a workspace:* protocol range each behave correctly
(fail, fail, pass respectively). npm ci exits 0 with no flags, npm run
validate exits 0, and the full suite is now 0 failed across all 8 projects.

Filed #85 for what this pass could not fix in scope: the guard step runs
after npm ci, so on real drift the install 404s first and the guard never
executes. Making it dependency-free enough to run pre-install is new
capability, not a fix to this diff.

Refs #83, #85
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