fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765

Open
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend
Open

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#14294

--tier is a third shape of this CLI's stdout, and it 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 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

dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

One line on stderr, exit 2, empty stdout — the same shape as the --commands --json refusal it extends and the --ran --tier refusal 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 --tier MEANS 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.

--tier on its own is untouched. Verified byte-for-byte: the stdout of --tier PATH after this change is identical to the stdout the blended --tier --commands PATH produced before it (diff clean, 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:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=0
$ wc -c < out.txt
942
$ head -1 out.txt
Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

--tier --json behaved 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 --commands promises commands and nothing else.)

AFTER — refused:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
$ node scripts/pm/dispatch-gates.mjs --tier --json packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

CONTROL, all three flags — unchanged by this PR:

$ node scripts/pm/dispatch-gates.mjs --tier --commands --json packages/spec/src/index.ts > out.txt
EXIT=2
dispatch-gates: --commands and --json are two spellings of stdout — pass one.

The new branch is ordered after the --commands --json pair 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):

  1. ⭐ --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.
  2. and its refusal names BOTH flags, so the dropped one is never left to be guessed
  3. ⭐ --tier against --json refuses the same way, on the other machine-readable shape
  4. and that refusal names --json, never the flag this run is not about
  5. CONTROL: all three together keep the pair rule that was already enforced

RED 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:

  • Mutation proved on disk before the run, not by an editor's exit code: the refusal message string went grep -c 1 to 0, git diff HEAD --stat showed 24 deletions(-), and the card's own repro was re-run against the mutated file and came back EXIT=0 stdout_bytes=942 — the pre-fix behaviour, restored on purpose.
  • Result: ✗ 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.
  • Restore leg proved by observation, not by exit code: git diff HEAD 0 bytes, git status --porcelain 0 lines, and the worktree blob hash 353abf85… equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carried trap ... EXIT INT TERM with absolute paths throughout.
  • No rebuild leg is owed here and none is claimed: the subject is a plain .mjs the battery spawns as process.execPath CLI from its own import.meta.url. There is no dist/, no package exports indirection, 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:

- [--residue] [--tier] [--commands | --json | --ran FILE] ...
+ [--residue] [--tier | --commands | --json | --ran FILE] ...

It advertised --tier as 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.

GateExitIts own verdict line
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1246 cases pass.
pnpm check:watch-hint-literal0✓ 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-live0✓ 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-guard0✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on import
pnpm check:parse-guard0(green; the 42-line tiered census printed, no finding)
node scripts/check-self-test-wired.mjs0✓ 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-bytes0check-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).
eslint --no-inline-config on the file00 errors, 0 warnings

Union re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9eab536 — change set 1 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:

Gate (derived, not briefed)Exit
node scripts/check-ci-filter-parity.mjs0
node scripts/check-shard-attestation.mjs0
node scripts/check-whole-set-label-write.mjs0
pnpm check:agent-test-spelling0
pnpm check:bash32-floor0
pnpm check:cli-command-ids0
pnpm check:cross-package-test-inputs0
pnpm check:pnpm-filter-targets0
node scripts/check-test-completeness.mjs3 — NOT MEASURED

check-test-completeness grades a saved turbo run test log and was handed none. Its own text names this exact arrival path: "Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? … 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 lint is CI's run, not this PR's):

  1. Population read from ESLint's own configuration, not guessed: eslint.config.mjs states in its own comment that "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", and it was measured there with a positive control.
  2. File count read from --format json: 1 file linted, 0 errors, 0 warnings.
  3. Invariance: with no type-aware linting enabled anywhere in that config, a one-file diff cannot move the verdict on any file it does not touch. The narrowing is therefore a measurement, not a gap.

Also self-scanned for raw control bytes beyond the gate: grep -naP over the edited file, no hits.

premise_false / notes for the reviewer

  • "the docblock sentence that states the two-spellings-of-stdout rule" — there is no docblock stating it. The rule lives in a line-comment block immediately above the --commands --json refusal in the argv chain (the phrase itself appears only in that console.error string). That comment block is what was extended to name --tier as a third shape; the header docblock's mode list was left alone.
  • Hot fileorigin/main has 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 of selfTest() — disjoint.
  • Watch-hint set unchanged, measured with the module's own extractWatchHints over 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-changeset verified against scripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it with grep -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 under scripts/pm/** and publishes nothing from any package.
  • Draft, and it stays draft. Governed surface (scripts/pm/**): human merge, never queued, never auto-merged.

Out-of-scope finding, filed not fixed

#14753--tier --residue silently drops --residue on the same argv chain. Measured with a control: --residue changes the derivation's answer (15007 → 66141 bytes) but under --tier it 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

…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
os-zhuang marked this pull request as ready for review September 3, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates: --tier --commands silently prefers --tier — the one two-stdout-shape blend the CLI does not refuse

3 participants

@os-litant@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765

Open
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend
Open

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#14294

--tier is a third shape of this CLI's stdout, and it 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 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

dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

One line on stderr, exit 2, empty stdout — the same shape as the --commands --json refusal it extends and the --ran --tier refusal 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 --tier MEANS 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.

--tier on its own is untouched. Verified byte-for-byte: the stdout of --tier PATH after this change is identical to the stdout the blended --tier --commands PATH produced before it (diff clean, 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:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=0
$ wc -c < out.txt
942
$ head -1 out.txt
Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

--tier --json behaved 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 --commands promises commands and nothing else.)

AFTER — refused:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
$ node scripts/pm/dispatch-gates.mjs --tier --json packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

CONTROL, all three flags — unchanged by this PR:

$ node scripts/pm/dispatch-gates.mjs --tier --commands --json packages/spec/src/index.ts > out.txt
EXIT=2
dispatch-gates: --commands and --json are two spellings of stdout — pass one.

The new branch is ordered after the --commands --json pair 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):

  1. ⭐ --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.
  2. and its refusal names BOTH flags, so the dropped one is never left to be guessed
  3. ⭐ --tier against --json refuses the same way, on the other machine-readable shape
  4. and that refusal names --json, never the flag this run is not about
  5. CONTROL: all three together keep the pair rule that was already enforced

RED 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:

  • Mutation proved on disk before the run, not by an editor's exit code: the refusal message string went grep -c 1 to 0, git diff HEAD --stat showed 24 deletions(-), and the card's own repro was re-run against the mutated file and came back EXIT=0 stdout_bytes=942 — the pre-fix behaviour, restored on purpose.
  • Result: ✗ 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.
  • Restore leg proved by observation, not by exit code: git diff HEAD 0 bytes, git status --porcelain 0 lines, and the worktree blob hash 353abf85… equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carried trap ... EXIT INT TERM with absolute paths throughout.
  • No rebuild leg is owed here and none is claimed: the subject is a plain .mjs the battery spawns as process.execPath CLI from its own import.meta.url. There is no dist/, no package exports indirection, 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:

- [--residue] [--tier] [--commands | --json | --ran FILE] ...
+ [--residue] [--tier | --commands | --json | --ran FILE] ...

It advertised --tier as 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.

GateExitIts own verdict line
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1246 cases pass.
pnpm check:watch-hint-literal0✓ 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-live0✓ 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-guard0✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on import
pnpm check:parse-guard0(green; the 42-line tiered census printed, no finding)
node scripts/check-self-test-wired.mjs0✓ 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-bytes0check-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).
eslint --no-inline-config on the file00 errors, 0 warnings

Union re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9eab536 — change set 1 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:

Gate (derived, not briefed)Exit
node scripts/check-ci-filter-parity.mjs0
node scripts/check-shard-attestation.mjs0
node scripts/check-whole-set-label-write.mjs0
pnpm check:agent-test-spelling0
pnpm check:bash32-floor0
pnpm check:cli-command-ids0
pnpm check:cross-package-test-inputs0
pnpm check:pnpm-filter-targets0
node scripts/check-test-completeness.mjs3 — NOT MEASURED

check-test-completeness grades a saved turbo run test log and was handed none. Its own text names this exact arrival path: "Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? … 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 lint is CI's run, not this PR's):

  1. Population read from ESLint's own configuration, not guessed: eslint.config.mjs states in its own comment that "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", and it was measured there with a positive control.
  2. File count read from --format json: 1 file linted, 0 errors, 0 warnings.
  3. Invariance: with no type-aware linting enabled anywhere in that config, a one-file diff cannot move the verdict on any file it does not touch. The narrowing is therefore a measurement, not a gap.

Also self-scanned for raw control bytes beyond the gate: grep -naP over the edited file, no hits.

premise_false / notes for the reviewer

  • "the docblock sentence that states the two-spellings-of-stdout rule" — there is no docblock stating it. The rule lives in a line-comment block immediately above the --commands --json refusal in the argv chain (the phrase itself appears only in that console.error string). That comment block is what was extended to name --tier as a third shape; the header docblock's mode list was left alone.
  • Hot fileorigin/main has 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 of selfTest() — disjoint.
  • Watch-hint set unchanged, measured with the module's own extractWatchHints over 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-changeset verified against scripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it with grep -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 under scripts/pm/** and publishes nothing from any package.
  • Draft, and it stays draft. Governed surface (scripts/pm/**): human merge, never queued, never auto-merged.

Out-of-scope finding, filed not fixed

#14753--tier --residue silently drops --residue on the same argv chain. Measured with a control: --residue changes the derivation's answer (15007 → 66141 bytes) but under --tier it 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

…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
os-zhuang marked this pull request as ready for review September 3, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates: --tier --commands silently prefers --tier — the one two-stdout-shape blend the CLI does not refuse

3 participants

@os-litant@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765

Open
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend
Open

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#14294

--tier is a third shape of this CLI's stdout, and it 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 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

dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

One line on stderr, exit 2, empty stdout — the same shape as the --commands --json refusal it extends and the --ran --tier refusal 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 --tier MEANS 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.

--tier on its own is untouched. Verified byte-for-byte: the stdout of --tier PATH after this change is identical to the stdout the blended --tier --commands PATH produced before it (diff clean, 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:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=0
$ wc -c < out.txt
942
$ head -1 out.txt
Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

--tier --json behaved 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 --commands promises commands and nothing else.)

AFTER — refused:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
$ node scripts/pm/dispatch-gates.mjs --tier --json packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

CONTROL, all three flags — unchanged by this PR:

$ node scripts/pm/dispatch-gates.mjs --tier --commands --json packages/spec/src/index.ts > out.txt
EXIT=2
dispatch-gates: --commands and --json are two spellings of stdout — pass one.

The new branch is ordered after the --commands --json pair 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):

  1. ⭐ --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.
  2. and its refusal names BOTH flags, so the dropped one is never left to be guessed
  3. ⭐ --tier against --json refuses the same way, on the other machine-readable shape
  4. and that refusal names --json, never the flag this run is not about
  5. CONTROL: all three together keep the pair rule that was already enforced

RED 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:

  • Mutation proved on disk before the run, not by an editor's exit code: the refusal message string went grep -c 1 to 0, git diff HEAD --stat showed 24 deletions(-), and the card's own repro was re-run against the mutated file and came back EXIT=0 stdout_bytes=942 — the pre-fix behaviour, restored on purpose.
  • Result: ✗ 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.
  • Restore leg proved by observation, not by exit code: git diff HEAD 0 bytes, git status --porcelain 0 lines, and the worktree blob hash 353abf85… equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carried trap ... EXIT INT TERM with absolute paths throughout.
  • No rebuild leg is owed here and none is claimed: the subject is a plain .mjs the battery spawns as process.execPath CLI from its own import.meta.url. There is no dist/, no package exports indirection, 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:

- [--residue] [--tier] [--commands | --json | --ran FILE] ...
+ [--residue] [--tier | --commands | --json | --ran FILE] ...

It advertised --tier as 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.

GateExitIts own verdict line
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1246 cases pass.
pnpm check:watch-hint-literal0✓ 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-live0✓ 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-guard0✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on import
pnpm check:parse-guard0(green; the 42-line tiered census printed, no finding)
node scripts/check-self-test-wired.mjs0✓ 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-bytes0check-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).
eslint --no-inline-config on the file00 errors, 0 warnings

Union re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9eab536 — change set 1 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:

Gate (derived, not briefed)Exit
node scripts/check-ci-filter-parity.mjs0
node scripts/check-shard-attestation.mjs0
node scripts/check-whole-set-label-write.mjs0
pnpm check:agent-test-spelling0
pnpm check:bash32-floor0
pnpm check:cli-command-ids0
pnpm check:cross-package-test-inputs0
pnpm check:pnpm-filter-targets0
node scripts/check-test-completeness.mjs3 — NOT MEASURED

check-test-completeness grades a saved turbo run test log and was handed none. Its own text names this exact arrival path: "Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? … 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 lint is CI's run, not this PR's):

  1. Population read from ESLint's own configuration, not guessed: eslint.config.mjs states in its own comment that "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", and it was measured there with a positive control.
  2. File count read from --format json: 1 file linted, 0 errors, 0 warnings.
  3. Invariance: with no type-aware linting enabled anywhere in that config, a one-file diff cannot move the verdict on any file it does not touch. The narrowing is therefore a measurement, not a gap.

Also self-scanned for raw control bytes beyond the gate: grep -naP over the edited file, no hits.

premise_false / notes for the reviewer

  • "the docblock sentence that states the two-spellings-of-stdout rule" — there is no docblock stating it. The rule lives in a line-comment block immediately above the --commands --json refusal in the argv chain (the phrase itself appears only in that console.error string). That comment block is what was extended to name --tier as a third shape; the header docblock's mode list was left alone.
  • Hot fileorigin/main has 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 of selfTest() — disjoint.
  • Watch-hint set unchanged, measured with the module's own extractWatchHints over 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-changeset verified against scripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it with grep -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 under scripts/pm/** and publishes nothing from any package.
  • Draft, and it stays draft. Governed surface (scripts/pm/**): human merge, never queued, never auto-merged.

Out-of-scope finding, filed not fixed

#14753--tier --residue silently drops --residue on the same argv chain. Measured with a control: --residue changes the derivation's answer (15007 → 66141 bytes) but under --tier it 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

…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
os-zhuang marked this pull request as ready for review September 3, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates: --tier --commands silently prefers --tier — the one two-stdout-shape blend the CLI does not refuse

3 participants

@os-litant@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765

Open
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend
Open

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#14294

--tier is a third shape of this CLI's stdout, and it 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 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

dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

One line on stderr, exit 2, empty stdout — the same shape as the --commands --json refusal it extends and the --ran --tier refusal 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 --tier MEANS 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.

--tier on its own is untouched. Verified byte-for-byte: the stdout of --tier PATH after this change is identical to the stdout the blended --tier --commands PATH produced before it (diff clean, 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:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=0
$ wc -c < out.txt
942
$ head -1 out.txt
Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

--tier --json behaved 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 --commands promises commands and nothing else.)

AFTER — refused:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
$ node scripts/pm/dispatch-gates.mjs --tier --json packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

CONTROL, all three flags — unchanged by this PR:

$ node scripts/pm/dispatch-gates.mjs --tier --commands --json packages/spec/src/index.ts > out.txt
EXIT=2
dispatch-gates: --commands and --json are two spellings of stdout — pass one.

The new branch is ordered after the --commands --json pair 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):

  1. ⭐ --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.
  2. and its refusal names BOTH flags, so the dropped one is never left to be guessed
  3. ⭐ --tier against --json refuses the same way, on the other machine-readable shape
  4. and that refusal names --json, never the flag this run is not about
  5. CONTROL: all three together keep the pair rule that was already enforced

RED 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:

  • Mutation proved on disk before the run, not by an editor's exit code: the refusal message string went grep -c 1 to 0, git diff HEAD --stat showed 24 deletions(-), and the card's own repro was re-run against the mutated file and came back EXIT=0 stdout_bytes=942 — the pre-fix behaviour, restored on purpose.
  • Result: ✗ 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.
  • Restore leg proved by observation, not by exit code: git diff HEAD 0 bytes, git status --porcelain 0 lines, and the worktree blob hash 353abf85… equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carried trap ... EXIT INT TERM with absolute paths throughout.
  • No rebuild leg is owed here and none is claimed: the subject is a plain .mjs the battery spawns as process.execPath CLI from its own import.meta.url. There is no dist/, no package exports indirection, 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:

- [--residue] [--tier] [--commands | --json | --ran FILE] ...
+ [--residue] [--tier | --commands | --json | --ran FILE] ...

It advertised --tier as 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.

GateExitIts own verdict line
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1246 cases pass.
pnpm check:watch-hint-literal0✓ 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-live0✓ 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-guard0✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on import
pnpm check:parse-guard0(green; the 42-line tiered census printed, no finding)
node scripts/check-self-test-wired.mjs0✓ 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-bytes0check-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).
eslint --no-inline-config on the file00 errors, 0 warnings

Union re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9eab536 — change set 1 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:

Gate (derived, not briefed)Exit
node scripts/check-ci-filter-parity.mjs0
node scripts/check-shard-attestation.mjs0
node scripts/check-whole-set-label-write.mjs0
pnpm check:agent-test-spelling0
pnpm check:bash32-floor0
pnpm check:cli-command-ids0
pnpm check:cross-package-test-inputs0
pnpm check:pnpm-filter-targets0
node scripts/check-test-completeness.mjs3 — NOT MEASURED

check-test-completeness grades a saved turbo run test log and was handed none. Its own text names this exact arrival path: "Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? … 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 lint is CI's run, not this PR's):

  1. Population read from ESLint's own configuration, not guessed: eslint.config.mjs states in its own comment that "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", and it was measured there with a positive control.
  2. File count read from --format json: 1 file linted, 0 errors, 0 warnings.
  3. Invariance: with no type-aware linting enabled anywhere in that config, a one-file diff cannot move the verdict on any file it does not touch. The narrowing is therefore a measurement, not a gap.

Also self-scanned for raw control bytes beyond the gate: grep -naP over the edited file, no hits.

premise_false / notes for the reviewer

  • "the docblock sentence that states the two-spellings-of-stdout rule" — there is no docblock stating it. The rule lives in a line-comment block immediately above the --commands --json refusal in the argv chain (the phrase itself appears only in that console.error string). That comment block is what was extended to name --tier as a third shape; the header docblock's mode list was left alone.
  • Hot fileorigin/main has 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 of selfTest() — disjoint.
  • Watch-hint set unchanged, measured with the module's own extractWatchHints over 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-changeset verified against scripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it with grep -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 under scripts/pm/** and publishes nothing from any package.
  • Draft, and it stays draft. Governed surface (scripts/pm/**): human merge, never queued, never auto-merged.

Out-of-scope finding, filed not fixed

#14753--tier --residue silently drops --residue on the same argv chain. Measured with a control: --residue changes the derivation's answer (15007 → 66141 bytes) but under --tier it 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

…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
os-zhuang marked this pull request as ready for review September 3, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates: --tier --commands silently prefers --tier — the one two-stdout-shape blend the CLI does not refuse

3 participants

@os-litant@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765

Open
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend
Open

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#14294

--tier is a third shape of this CLI's stdout, and it 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 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

dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

One line on stderr, exit 2, empty stdout — the same shape as the --commands --json refusal it extends and the --ran --tier refusal 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 --tier MEANS 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.

--tier on its own is untouched. Verified byte-for-byte: the stdout of --tier PATH after this change is identical to the stdout the blended --tier --commands PATH produced before it (diff clean, 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:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=0
$ wc -c < out.txt
942
$ head -1 out.txt
Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

--tier --json behaved 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 --commands promises commands and nothing else.)

AFTER — refused:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
$ node scripts/pm/dispatch-gates.mjs --tier --json packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

CONTROL, all three flags — unchanged by this PR:

$ node scripts/pm/dispatch-gates.mjs --tier --commands --json packages/spec/src/index.ts > out.txt
EXIT=2
dispatch-gates: --commands and --json are two spellings of stdout — pass one.

The new branch is ordered after the --commands --json pair 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):

  1. ⭐ --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.
  2. and its refusal names BOTH flags, so the dropped one is never left to be guessed
  3. ⭐ --tier against --json refuses the same way, on the other machine-readable shape
  4. and that refusal names --json, never the flag this run is not about
  5. CONTROL: all three together keep the pair rule that was already enforced

RED 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:

  • Mutation proved on disk before the run, not by an editor's exit code: the refusal message string went grep -c 1 to 0, git diff HEAD --stat showed 24 deletions(-), and the card's own repro was re-run against the mutated file and came back EXIT=0 stdout_bytes=942 — the pre-fix behaviour, restored on purpose.
  • Result: ✗ 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.
  • Restore leg proved by observation, not by exit code: git diff HEAD 0 bytes, git status --porcelain 0 lines, and the worktree blob hash 353abf85… equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carried trap ... EXIT INT TERM with absolute paths throughout.
  • No rebuild leg is owed here and none is claimed: the subject is a plain .mjs the battery spawns as process.execPath CLI from its own import.meta.url. There is no dist/, no package exports indirection, 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:

- [--residue] [--tier] [--commands | --json | --ran FILE] ...
+ [--residue] [--tier | --commands | --json | --ran FILE] ...

It advertised --tier as 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.

GateExitIts own verdict line
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1246 cases pass.
pnpm check:watch-hint-literal0✓ 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-live0✓ 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-guard0✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on import
pnpm check:parse-guard0(green; the 42-line tiered census printed, no finding)
node scripts/check-self-test-wired.mjs0✓ 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-bytes0check-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).
eslint --no-inline-config on the file00 errors, 0 warnings

Union re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9eab536 — change set 1 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:

Gate (derived, not briefed)Exit
node scripts/check-ci-filter-parity.mjs0
node scripts/check-shard-attestation.mjs0
node scripts/check-whole-set-label-write.mjs0
pnpm check:agent-test-spelling0
pnpm check:bash32-floor0
pnpm check:cli-command-ids0
pnpm check:cross-package-test-inputs0
pnpm check:pnpm-filter-targets0
node scripts/check-test-completeness.mjs3 — NOT MEASURED

check-test-completeness grades a saved turbo run test log and was handed none. Its own text names this exact arrival path: "Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? … 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 lint is CI's run, not this PR's):

  1. Population read from ESLint's own configuration, not guessed: eslint.config.mjs states in its own comment that "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", and it was measured there with a positive control.
  2. File count read from --format json: 1 file linted, 0 errors, 0 warnings.
  3. Invariance: with no type-aware linting enabled anywhere in that config, a one-file diff cannot move the verdict on any file it does not touch. The narrowing is therefore a measurement, not a gap.

Also self-scanned for raw control bytes beyond the gate: grep -naP over the edited file, no hits.

premise_false / notes for the reviewer

  • "the docblock sentence that states the two-spellings-of-stdout rule" — there is no docblock stating it. The rule lives in a line-comment block immediately above the --commands --json refusal in the argv chain (the phrase itself appears only in that console.error string). That comment block is what was extended to name --tier as a third shape; the header docblock's mode list was left alone.
  • Hot fileorigin/main has 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 of selfTest() — disjoint.
  • Watch-hint set unchanged, measured with the module's own extractWatchHints over 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-changeset verified against scripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it with grep -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 under scripts/pm/** and publishes nothing from any package.
  • Draft, and it stays draft. Governed surface (scripts/pm/**): human merge, never queued, never auto-merged.

Out-of-scope finding, filed not fixed

#14753--tier --residue silently drops --residue on the same argv chain. Measured with a control: --residue changes the derivation's answer (15007 → 66141 bytes) but under --tier it 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

…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
os-zhuang marked this pull request as ready for review September 3, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates: --tier --commands silently prefers --tier — the one two-stdout-shape blend the CLI does not refuse

3 participants

@os-litant@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765

Open
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend
Open

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#14294

--tier is a third shape of this CLI's stdout, and it 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 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

dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

One line on stderr, exit 2, empty stdout — the same shape as the --commands --json refusal it extends and the --ran --tier refusal 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 --tier MEANS 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.

--tier on its own is untouched. Verified byte-for-byte: the stdout of --tier PATH after this change is identical to the stdout the blended --tier --commands PATH produced before it (diff clean, 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:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=0
$ wc -c < out.txt
942
$ head -1 out.txt
Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

--tier --json behaved 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 --commands promises commands and nothing else.)

AFTER — refused:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
$ node scripts/pm/dispatch-gates.mjs --tier --json packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

CONTROL, all three flags — unchanged by this PR:

$ node scripts/pm/dispatch-gates.mjs --tier --commands --json packages/spec/src/index.ts > out.txt
EXIT=2
dispatch-gates: --commands and --json are two spellings of stdout — pass one.

The new branch is ordered after the --commands --json pair 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):

  1. ⭐ --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.
  2. and its refusal names BOTH flags, so the dropped one is never left to be guessed
  3. ⭐ --tier against --json refuses the same way, on the other machine-readable shape
  4. and that refusal names --json, never the flag this run is not about
  5. CONTROL: all three together keep the pair rule that was already enforced

RED 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:

  • Mutation proved on disk before the run, not by an editor's exit code: the refusal message string went grep -c 1 to 0, git diff HEAD --stat showed 24 deletions(-), and the card's own repro was re-run against the mutated file and came back EXIT=0 stdout_bytes=942 — the pre-fix behaviour, restored on purpose.
  • Result: ✗ 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.
  • Restore leg proved by observation, not by exit code: git diff HEAD 0 bytes, git status --porcelain 0 lines, and the worktree blob hash 353abf85… equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carried trap ... EXIT INT TERM with absolute paths throughout.
  • No rebuild leg is owed here and none is claimed: the subject is a plain .mjs the battery spawns as process.execPath CLI from its own import.meta.url. There is no dist/, no package exports indirection, 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:

- [--residue] [--tier] [--commands | --json | --ran FILE] ...
+ [--residue] [--tier | --commands | --json | --ran FILE] ...

It advertised --tier as 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.

GateExitIts own verdict line
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1246 cases pass.
pnpm check:watch-hint-literal0✓ 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-live0✓ 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-guard0✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on import
pnpm check:parse-guard0(green; the 42-line tiered census printed, no finding)
node scripts/check-self-test-wired.mjs0✓ 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-bytes0check-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).
eslint --no-inline-config on the file00 errors, 0 warnings

Union re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9eab536 — change set 1 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:

Gate (derived, not briefed)Exit
node scripts/check-ci-filter-parity.mjs0
node scripts/check-shard-attestation.mjs0
node scripts/check-whole-set-label-write.mjs0
pnpm check:agent-test-spelling0
pnpm check:bash32-floor0
pnpm check:cli-command-ids0
pnpm check:cross-package-test-inputs0
pnpm check:pnpm-filter-targets0
node scripts/check-test-completeness.mjs3 — NOT MEASURED

check-test-completeness grades a saved turbo run test log and was handed none. Its own text names this exact arrival path: "Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? … 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 lint is CI's run, not this PR's):

  1. Population read from ESLint's own configuration, not guessed: eslint.config.mjs states in its own comment that "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", and it was measured there with a positive control.
  2. File count read from --format json: 1 file linted, 0 errors, 0 warnings.
  3. Invariance: with no type-aware linting enabled anywhere in that config, a one-file diff cannot move the verdict on any file it does not touch. The narrowing is therefore a measurement, not a gap.

Also self-scanned for raw control bytes beyond the gate: grep -naP over the edited file, no hits.

premise_false / notes for the reviewer

  • "the docblock sentence that states the two-spellings-of-stdout rule" — there is no docblock stating it. The rule lives in a line-comment block immediately above the --commands --json refusal in the argv chain (the phrase itself appears only in that console.error string). That comment block is what was extended to name --tier as a third shape; the header docblock's mode list was left alone.
  • Hot fileorigin/main has 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 of selfTest() — disjoint.
  • Watch-hint set unchanged, measured with the module's own extractWatchHints over 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-changeset verified against scripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it with grep -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 under scripts/pm/** and publishes nothing from any package.
  • Draft, and it stays draft. Governed surface (scripts/pm/**): human merge, never queued, never auto-merged.

Out-of-scope finding, filed not fixed

#14753--tier --residue silently drops --residue on the same argv chain. Measured with a control: --residue changes the derivation's answer (15007 → 66141 bytes) but under --tier it 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

…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
os-zhuang marked this pull request as ready for review September 3, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates: --tier --commands silently prefers --tier — the one two-stdout-shape blend the CLI does not refuse

3 participants

@os-litant@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765

Open
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend
Open

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#14294

--tier is a third shape of this CLI's stdout, and it 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 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

dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

One line on stderr, exit 2, empty stdout — the same shape as the --commands --json refusal it extends and the --ran --tier refusal 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 --tier MEANS 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.

--tier on its own is untouched. Verified byte-for-byte: the stdout of --tier PATH after this change is identical to the stdout the blended --tier --commands PATH produced before it (diff clean, 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:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=0
$ wc -c < out.txt
942
$ head -1 out.txt
Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

--tier --json behaved 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 --commands promises commands and nothing else.)

AFTER — refused:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
$ node scripts/pm/dispatch-gates.mjs --tier --json packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

CONTROL, all three flags — unchanged by this PR:

$ node scripts/pm/dispatch-gates.mjs --tier --commands --json packages/spec/src/index.ts > out.txt
EXIT=2
dispatch-gates: --commands and --json are two spellings of stdout — pass one.

The new branch is ordered after the --commands --json pair 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):

  1. ⭐ --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.
  2. and its refusal names BOTH flags, so the dropped one is never left to be guessed
  3. ⭐ --tier against --json refuses the same way, on the other machine-readable shape
  4. and that refusal names --json, never the flag this run is not about
  5. CONTROL: all three together keep the pair rule that was already enforced

RED 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:

  • Mutation proved on disk before the run, not by an editor's exit code: the refusal message string went grep -c 1 to 0, git diff HEAD --stat showed 24 deletions(-), and the card's own repro was re-run against the mutated file and came back EXIT=0 stdout_bytes=942 — the pre-fix behaviour, restored on purpose.
  • Result: ✗ 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.
  • Restore leg proved by observation, not by exit code: git diff HEAD 0 bytes, git status --porcelain 0 lines, and the worktree blob hash 353abf85… equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carried trap ... EXIT INT TERM with absolute paths throughout.
  • No rebuild leg is owed here and none is claimed: the subject is a plain .mjs the battery spawns as process.execPath CLI from its own import.meta.url. There is no dist/, no package exports indirection, 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:

- [--residue] [--tier] [--commands | --json | --ran FILE] ...
+ [--residue] [--tier | --commands | --json | --ran FILE] ...

It advertised --tier as 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.

GateExitIts own verdict line
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1246 cases pass.
pnpm check:watch-hint-literal0✓ 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-live0✓ 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-guard0✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on import
pnpm check:parse-guard0(green; the 42-line tiered census printed, no finding)
node scripts/check-self-test-wired.mjs0✓ 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-bytes0check-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).
eslint --no-inline-config on the file00 errors, 0 warnings

Union re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9eab536 — change set 1 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:

Gate (derived, not briefed)Exit
node scripts/check-ci-filter-parity.mjs0
node scripts/check-shard-attestation.mjs0
node scripts/check-whole-set-label-write.mjs0
pnpm check:agent-test-spelling0
pnpm check:bash32-floor0
pnpm check:cli-command-ids0
pnpm check:cross-package-test-inputs0
pnpm check:pnpm-filter-targets0
node scripts/check-test-completeness.mjs3 — NOT MEASURED

check-test-completeness grades a saved turbo run test log and was handed none. Its own text names this exact arrival path: "Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? … 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 lint is CI's run, not this PR's):

  1. Population read from ESLint's own configuration, not guessed: eslint.config.mjs states in its own comment that "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", and it was measured there with a positive control.
  2. File count read from --format json: 1 file linted, 0 errors, 0 warnings.
  3. Invariance: with no type-aware linting enabled anywhere in that config, a one-file diff cannot move the verdict on any file it does not touch. The narrowing is therefore a measurement, not a gap.

Also self-scanned for raw control bytes beyond the gate: grep -naP over the edited file, no hits.

premise_false / notes for the reviewer

  • "the docblock sentence that states the two-spellings-of-stdout rule" — there is no docblock stating it. The rule lives in a line-comment block immediately above the --commands --json refusal in the argv chain (the phrase itself appears only in that console.error string). That comment block is what was extended to name --tier as a third shape; the header docblock's mode list was left alone.
  • Hot fileorigin/main has 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 of selfTest() — disjoint.
  • Watch-hint set unchanged, measured with the module's own extractWatchHints over 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-changeset verified against scripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it with grep -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 under scripts/pm/** and publishes nothing from any package.
  • Draft, and it stays draft. Governed surface (scripts/pm/**): human merge, never queued, never auto-merged.

Out-of-scope finding, filed not fixed

#14753--tier --residue silently drops --residue on the same argv chain. Measured with a control: --residue changes the derivation's answer (15007 → 66141 bytes) but under --tier it 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

…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
os-zhuang marked this pull request as ready for review September 3, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates: --tier --commands silently prefers --tier — the one two-stdout-shape blend the CLI does not refuse

3 participants

@os-litant@os-zhuang@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier - #14765

Open
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend
Open

fix(pm): refuse --tier against --commands / --json instead of silently preferring the tier#14765
os-litant wants to merge 1 commit into
mainfrom
claude/issue-14294-tier-commands-blend

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#14294

--tier is a third shape of this CLI's stdout, and it 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 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

dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

One line on stderr, exit 2, empty stdout — the same shape as the --commands --json refusal it extends and the --ran --tier refusal 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 --tier MEANS 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.

--tier on its own is untouched. Verified byte-for-byte: the stdout of --tier PATH after this change is identical to the stdout the blended --tier --commands PATH produced before it (diff clean, 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:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=0
$ wc -c < out.txt
942
$ head -1 out.txt
Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

--tier --json behaved 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 --commands promises commands and nothing else.)

AFTER — refused:

$ node scripts/pm/dispatch-gates.mjs --tier --commands packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --commands are two spellings of stdout — pass one.
$ node scripts/pm/dispatch-gates.mjs --tier --json packages/spec/src/index.ts > out.txt
$ echo "EXIT=$?"
EXIT=2
$ wc -c < out.txt
0
$ # stderr:
dispatch-gates: --tier and --json are two spellings of stdout — pass one.

CONTROL, all three flags — unchanged by this PR:

$ node scripts/pm/dispatch-gates.mjs --tier --commands --json packages/spec/src/index.ts > out.txt
EXIT=2
dispatch-gates: --commands and --json are two spellings of stdout — pass one.

The new branch is ordered after the --commands --json pair 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):

  1. ⭐ --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.
  2. and its refusal names BOTH flags, so the dropped one is never left to be guessed
  3. ⭐ --tier against --json refuses the same way, on the other machine-readable shape
  4. and that refusal names --json, never the flag this run is not about
  5. CONTROL: all three together keep the pair rule that was already enforced

RED 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:

  • Mutation proved on disk before the run, not by an editor's exit code: the refusal message string went grep -c 1 to 0, git diff HEAD --stat showed 24 deletions(-), and the card's own repro was re-run against the mutated file and came back EXIT=0 stdout_bytes=942 — the pre-fix behaviour, restored on purpose.
  • Result: ✗ 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.
  • Restore leg proved by observation, not by exit code: git diff HEAD 0 bytes, git status --porcelain 0 lines, and the worktree blob hash 353abf85… equal to the HEAD blob hash; the repro then re-run once more and back to EXIT=2. The ablation script carried trap ... EXIT INT TERM with absolute paths throughout.
  • No rebuild leg is owed here and none is claimed: the subject is a plain .mjs the battery spawns as process.execPath CLI from its own import.meta.url. There is no dist/, no package exports indirection, 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:

- [--residue] [--tier] [--commands | --json | --ran FILE] ...
+ [--residue] [--tier | --commands | --json | --ran FILE] ...

It advertised --tier as 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.

GateExitIts own verdict line
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1246 cases pass.
pnpm check:watch-hint-literal0✓ 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-live0✓ 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-guard0✓ check:entry-guard: 199 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 150 export bindings, 150 of them inert on import
pnpm check:parse-guard0(green; the 42-line tiered census printed, no finding)
node scripts/check-self-test-wired.mjs0✓ 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-bytes0check-nul-bytes: OK (scanned 8052 text file(s) … no raw ASCII control bytes).
eslint --no-inline-config on the file00 errors, 0 warnings

Union re-derived AFTER the last edit, no hand-fed paths:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 9eab536 — change set 1 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:

Gate (derived, not briefed)Exit
node scripts/check-ci-filter-parity.mjs0
node scripts/check-shard-attestation.mjs0
node scripts/check-whole-set-label-write.mjs0
pnpm check:agent-test-spelling0
pnpm check:bash32-floor0
pnpm check:cli-command-ids0
pnpm check:cross-package-test-inputs0
pnpm check:pnpm-filter-targets0
node scripts/check-test-completeness.mjs3 — NOT MEASURED

check-test-completeness grades a saved turbo run test log and was handed none. Its own text names this exact arrival path: "Arrived here from the gate family scripts/pm/dispatch-gates.mjs derives? … 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 lint is CI's run, not this PR's):

  1. Population read from ESLint's own configuration, not guessed: eslint.config.mjs states in its own comment that "this repo runs one eslint.config.mjs, which never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", and it was measured there with a positive control.
  2. File count read from --format json: 1 file linted, 0 errors, 0 warnings.
  3. Invariance: with no type-aware linting enabled anywhere in that config, a one-file diff cannot move the verdict on any file it does not touch. The narrowing is therefore a measurement, not a gap.

Also self-scanned for raw control bytes beyond the gate: grep -naP over the edited file, no hits.

premise_false / notes for the reviewer

  • "the docblock sentence that states the two-spellings-of-stdout rule" — there is no docblock stating it. The rule lives in a line-comment block immediately above the --commands --json refusal in the argv chain (the phrase itself appears only in that console.error string). That comment block is what was extended to name --tier as a third shape; the header docblock's mode list was left alone.
  • Hot fileorigin/main has 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 of selfTest() — disjoint.
  • Watch-hint set unchanged, measured with the module's own extractWatchHints over 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-changeset verified against scripts/check-empty-changeset.mjs's own enumeration before applying: the label is a live mechanism here (the changeset workflow reads it with grep -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 under scripts/pm/** and publishes nothing from any package.
  • Draft, and it stays draft. Governed surface (scripts/pm/**): human merge, never queued, never auto-merged.

Out-of-scope finding, filed not fixed

#14753--tier --residue silently drops --residue on the same argv chain. Measured with a control: --residue changes the derivation's answer (15007 → 66141 bytes) but under --tier it 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

…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
os-zhuang marked this pull request as ready for review September 3, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates: --tier --commands silently prefers --tier — the one two-stdout-shape blend the CLI does not refuse

3 participants

@os-litant@os-zhuang@claude