Uh oh!
There was an error while loading. Please reload this page.
fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765
Open
os-litant wants to merge 1 commit into
Open
fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765os-litant wants to merge 1 commit into
--tier against --commands / --json instead of silently preferring the tier#14765os-litant wants to merge 1 commit into
Conversation
…preferring the tier `--tier` is a third shape of stdout and was the one the "two spellings of stdout" rule did not name. `mode` was computed from --commands/--json and then discarded by the `--tier` branch, so the tier prose landed in a stream whose caption promises one runnable command per line, or one JSON document — at exit 0, with nothing on either stream saying the flag had been dropped. Both pairs now refuse with the shape the existing refusals use: exit 2, one `dispatch-gates: …` line on stderr naming both flags. Ordered after the `--commands --json` pair so all three together keep the message they had. `--tier` on its own is byte-identical to before. Fixes#14294 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1
os-zhuang
approved these changes
Sep 3, 2026
os-zhuang
marked this pull request as ready for review
September 3, 2026 01:14
This was referenced Sep 3, 2026
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#14294
--tieris a third shape of this CLI's stdout, and it was the one the "two spellings of stdout" rule did not name.modewas computed from--commands/--jsonand then discarded by the--tierbranch at the bottom of the same argv block, so the tier prose landed in a stream whose caption promises one runnable command per line, or one JSON document — at exit 0, with nothing on either stream saying the flag the caller passed had been dropped.Both pairs now refuse, in the shape the neighbouring refusals already use.
The refusal
One line on stderr, exit 2, empty stdout — the same shape as the
--commands --jsonrefusal it extends and the--ran --tierrefusal above it, and for the same stated reason: two answers to "what shape is stdout" is no answer.⛔ Not re-routed to stderr. Sending the tier verdict to stderr whenever a machine-readable mode is asked for is defensible, and #14294 says so — but it changes what
--tierMEANS when combined, which is a design call for this file's owner, not a repair to a silent-drop bug. The seat resolved the card's open fork to "refuse" in lane, and this PR implements only that.⛔
--tieron its own is untouched. Verified byte-for-byte: the stdout of--tier PATHafter this change is identical to the stdout the blended--tier --commands PATHproduced before it (diffclean, 942 bytes both).Before / after — the card's own reproduction
Run on
packages/spec/src/index.ts, exactly as #14294 spells it. Exit codes captured by redirect BEFORE any pipe.BEFORE (base tree, 7a17f3b) — the silent drop:
--tier --jsonbehaved identically: EXIT=0, the same 942 bytes of tier prose, and a stream that is not JSON with nothing saying so.(The 4 further lines of that stdout are elided here on purpose: they name model tiers, which do not belong in a PR body. The point of the transcript is that they were on stdout at all, in a file the caller redirected because
--commandspromises commands and nothing else.)AFTER — refused:
CONTROL, all three flags — unchanged by this PR:
The new branch is ordered after the
--commands --jsonpair deliberately, so the triple keeps the message it already had. This PR adds a refusal and rewords none.Self-test cases — red then green
Five cases added beside the pair they extend, in the seam-card block of
selfTest()(not the tier region, which PR #14672 holds):⭐ --tier against --commands refuses instead of silently preferring the tier— status 2 AND empty stdout, because a refusal that still prints an answer is the bug wearing an exit code.and its refusal names BOTH flags, so the dropped one is never left to be guessed⭐ --tier against --json refuses the same way, on the other machine-readable shapeand that refusal names --json, never the flag this run is not aboutCONTROL: all three together keep the pair rule that was already enforcedRED leg (ablation). The implementation was committed first, then the refusal branch alone was deleted — the five cases left in place — and the full battery re-run on that mutated tree:
grep -c1 to 0,git diff HEAD --statshowed24 deletions(-), and the card's own repro was re-run against the mutated file and came backEXIT=0 stdout_bytes=942— the pre-fix behaviour, restored on purpose.✗ dispatch-gates self-test: 4 of 1246 case(s) failed.— exactly cases 1-4. Case 5 stayed green, which is what makes it a control rather than a case that would redden for any reason.git diff HEAD0 bytes,git status --porcelain0 lines, and the worktree blob hash353abf85…equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carriedtrap ... EXIT INT TERMwith absolute paths throughout..mjsthe battery spawns asprocess.execPath CLIfrom its ownimport.meta.url. There is nodist/, no packageexportsindirection, so there is no stale-artifact path between the edit and the measurement.GREEN leg. Same battery, same commit as this PR's head:
✓ dispatch-gates self-test: 1246 cases pass., 0 failure lines.Case count. After: 1246, measured. Before: 1241, derived — the diff adds exactly 5
t(calls and removes 0 (grep -cE '^\+\s+t\('= 5,'^-\s+t\('= 0), and the ablation run reports the same 1246 total, so the base total is 1246 − 5. Labelled derived rather than measured because no battery was run on a tree without the cases; that run would have cost a third six-minute hold of the shared verify lock for a bookkeeping number.Bounded in-place correction, declared
One line beyond the refusal and its cases — the usage string printed on the derivation-failure path:
It advertised
--tieras freely combinable with the group this PR just made it exclusive with. Left alone it would be a documented lie shipped in the same commit that makes it false — the same silent-wrong-answer class the file exists against. Mechanical, one line, inside the argv block this PR already owns, no new verification surface. Flagged here rather than left for a reviewer to find.Gates
All under
scripts/pm/os-verify-lock.sh; every exit code captured by redirect before any pipe; each verdict line quoted from the gate's own output. Head 9eab536.pnpm check:pm-dispatch-gates✓ dispatch-gates self-test: 1246 cases pass.pnpm check:watch-hint-literal✓ check-watch-hint-literal: 47 declaration(s) across 4 rostered name(s) … no unrostered spelling of the idiom in the tree.pnpm check:declared-population-live✓ check:declared-population-live — 158 of 202 famil(ies) declare a path population, and every one of them reaches this tree's 8059 tracked file(s).pnpm check:entry-guard✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on importpnpm check:parse-guardnode scripts/check-self-test-wired.mjs✓ check-self-test-wired: every one of the 164 script(s) CI runs that ship a --self-test has that self-test run by CI.pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).eslint --no-inline-configon the fileUnion re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsat 9eab536 — change set1 path(s), exactly this file;16 command(s) — 10 pnpm, 6 direct node. The nine families the dispatch brief did not name were run too:node scripts/check-ci-filter-parity.mjsnode scripts/check-shard-attestation.mjsnode scripts/check-whole-set-label-write.mjspnpm check:agent-test-spellingpnpm check:bash32-floorpnpm check:cli-command-idspnpm check:cross-package-test-inputspnpm check:pnpm-filter-targetsnode scripts/check-test-completeness.mjscheck-test-completenessgrades a savedturbo run testlog and was handed none. Its own text names this exact arrival path: "Arrived here from the gate familyscripts/pm/dispatch-gates.mjsderives? … the local reading for this gate is NOT MEASURED. ⛔ It is not a red, and there is nothing here to fix." Recorded as NOT MEASURED — not green, not red, not a flake.ESLint narrowing, declared with its three pieces of evidence (the repo-wide
pnpm lintis CI's run, not this PR's):eslint.config.mjsstates in its own comment that "this repo runs oneeslint.config.mjs, which never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file, test or not", and it was measured there with a positive control.--format json: 1 file linted, 0 errors, 0 warnings.Also self-scanned for raw control bytes beyond the gate:
grep -naPover the edited file, no hits.premise_false / notes for the reviewer
--commands --jsonrefusal in the argv chain (the phrase itself appears only in thatconsole.errorstring). That comment block is what was extended to name--tieras a third shape; the header docblock's mode list was left alone.origin/mainhas not moved at all since this branch was cut (git rev-list --count BASE..origin/main= 0), and PR test(pm): pin the contract-review tier's one-value-site promise in the dispatch-gates self-test (#14616) #14672 has not landed, so no merge was needed. My hunks are the argv block plus five cases in the seam-card block; test(pm): pin the contract-review tier's one-value-site promise in the dispatch-gates self-test (#14616) #14672's are in the tier region ofselfTest()— disjoint.extractWatchHintsover its own source before and after the edit: byte-identical JSON, 23 hints both ways. No path literal was added and the tier constant's value is spelled nowhere in the new cases.skip-changesetverified againstscripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it withgrep -qxF 'skip-changeset', and that script pins exactly two such reads), and route 2 is the destination for a PR that releases nothing. This diff is one file underscripts/pm/**and publishes nothing from any package.scripts/pm/**): human merge, never queued, never auto-merged.Out-of-scope finding, filed not fixed
#14753 —
--tier --residuesilently drops--residueon the same argv chain. Measured with a control:--residuechanges the derivation's answer (15007 → 66141 bytes) but under--tierit is byte-for-byte a no-op at exit 0. Different axis from this card (a modifier, not a stdout shape), so it was filed unassigned rather than folded in here. Out of scope for this PR, which does not address it.🤖 Generated with Claude Code
https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1
Generated by Claude Code