tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key - #15114

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv
Sep 4, 2026
Merged

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key#15114
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#15083

dispatch-gates keys the derivation on (script, args) and, since PR #15081, admitted the argv half only when the tail was a complete run of flag-shaped tokens. Nine scripts whose only CI invocations carry a value or a line continuation therefore kept a bare path key CI never runs bare. This renders those invocations instead, classifying each argv token from the workflow text.

Verification head sha: e98bfc87. Every gate below and every number in this body was run or read at that commit.

1. The measurement — every CI invocation of the nine, token by token

Read from .github/workflows/*.yml at origin/main (369da918). A literal appears verbatim in the workflow and is the same on every run; a variable is a GitHub expression, a shell expansion, a step output, or a path an earlier step produced.

ScriptWorkflow · job / stepInvocation as CI runs itValue tokensClass
check-adr-0087-registrationpr-automation.yml · "Require an ADR-0087 disposition…"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASE (env from steps.diffbase.outputs.merge_base)VARIABLE
cut-rc.yml · "Gate — this cut introduces no unregistered breaking change"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-changeset-no-majorpr-automation.yml · "…no major bump" (one-liner)--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no major bump"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-empty-changesetpr-automation.yml · "…no empty-frontmatter changeset"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no empty-frontmatter changeset"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-engine-split-ratiolint.yml · shallow-history guard collector--self-testnoneLITERAL
engine-split-metric.yml · "Compute ratio (90-day window)"--days 9090 written in the workflowLITERAL
check-test-completenessci.yml · Test Core "Test completeness guard""$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" (continued over 3 lines)$RUNNER_TEMPVARIABLE
ci.yml · Dogfood "Test completeness guard""$RUNNER_TEMP/dogfood.log"$RUNNER_TEMPVARIABLE
check-shard-attestationci.yml · test "Attest this shard ran and passed"--emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMP (test, 6 are literals)VARIABLE
ci.yml · dogfood "Attest this shard ran and passed"--emit --job dogfood --shard ${{ matrix.shard }} --total 3 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMPVARIABLE
ci.yml · dogfood-verify "Attest this leg ran and passed"--emit --job dogfood-verify --shard 1 --total 1 --out "$RUNNER_TEMP/shard-attestation"$RUNNER_TEMP only (1, 1 are literals)VARIABLE
ci.yml · test-gate "Verify test shard results"--verify --gate 'Test Core' --dir "$OS_ATTEST_DIR" --filter-result "$OS_FILTER_RESULT" --download-outcome "$OS_DOWNLOAD_OUTCOME" --leg "test/6:$OS_TEST_RESULT"4 env vars ('Test Core' is a literal)VARIABLE
ci.yml · dogfood-gate "Verify dogfood shard results"--verify --gate 'Dogfood Regression Gate' --dir … --leg "dogfood/3:$OS_DOGFOOD_RESULT" --leg "dogfood-verify/1:$OS_VERIFY_RESULT"5 env varsVARIABLE
check-required-contextsrequired-set-patrol.yml · "Run the live required-set sweep"--verify-required-set (continued, then two redirections)none — the continuation carries only redirectionsLITERAL
check-cross-package-test-inputsci.yml · filter job, affected-set union--union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt" (continued over 3 lines)$RUNNER_TEMPVARIABLE
check-prerelease-pin-watchprerelease-pin-watch.yml · "Self-test the probe"--self-testnoneLITERAL
prerelease-pin-watch.yml · "Probe npm for a stable release"--verbose then 2 redirected to 1none — the 2 was the redirection's own file descriptorLITERAL

Three of the nine are not value-bearing at all.check-engine-split-ratio --days 90 writes its value down; check-required-contexts --verify-required-set and check-prerelease-pin-watch --verbose have complete argv that were refused only because a continuation and a redirection file descriptor stood behind them. Those three now render as runnable commands.

The split reads off the workflow text for all fifteen invocations, so the triage's ⛔ holds: no per-script declaration table is owed.

Two members the card's table does not name

Re-measured at origin/main rather than taken from the card:

  • scripts/pm/check-half-states.mjs is a tenth member of the same class.half-state-patrol.yml runs it --format=markdown --provenance="$PROVENANCE" (continued, then redirected) and nowhere else, where PROVENANCE is a workflow env built from github.run_id / github.sha / github.event_name. It derived under a bare key too.
  • check-release-section-coverage.mjs --strict is a second invocation in release-coverage-patrol.yml that had no entry of its own — a continuation hid it, and the script's bare key (which lint.yml really does run) absorbed it. It is now its own runnable family, and the bare key stays where CI runs it bare.

What the bare keys actually did, run at origin/main

This is why the bare key is not a neutral omission:

Bare invocationexitfirst line
node scripts/check-test-completeness.mjs3PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.
node scripts/check-required-contexts.mjs0runs the static pin check — required-set-patrol.yml runs the live--verify-required-set sweep
node scripts/check-shard-attestation.mjs0duplicates what pnpm check:shard-attestation (a separate family) already runs; ci.yml's --emit/--verify runs are a different program
node scripts/check-cross-package-test-inputs.mjs0same shape — pnpm check:cross-package-test-inputs already covers it
the three --base gates0diff against their default base, while CI pins the merge base or the cut snapshot
node scripts/check-engine-split-ratio.mjs2shallow-clone refusal in this container; engine-split-metric.yml checks out with fetch-depth: 0

2. What changed in scripts/pm/dispatch-gates.mjs

  • joinLineContinuations splices a continued command into one line before any matcher runs, so the captured tail is the whole argv rather than the first physical line of it. ⛔ A comment line is never joined — a # comment runs to the newline, so its trailing backslash is comment text (measured: 107 continuation lines inside run: bodies on this tree, zero of them comments).
  • tailBeforeRedirection drops a redirection's file descriptor. DIRECT_CHECK_INVOCATION correctly stops the tail before the redirection operator, but 2 redirected to 1 puts its fd on the argv side of that boundary, so --verbose captured as --verbose 2. Discriminator is the shell's own: digits touching the operator are a descriptor, digits with a space before it are an argument.
  • argvTokens is a quote-aware tokenizer that also holds a ${{ … }} expression together — both spellings are live in ci.yml (--gate 'Test Core', --shard ${{ matrix.shard }}) and a bare whitespace split would classify their pieces separately.
  • renderedArgv replaces renderableArgv. It renders every tail and returns the workflow values it carries, so the caller decides what to do rather than the classifier refusing.
  • entry.notRunnable is the second not-runnable-here classification beside entry.ciOnly, and is deliberately kept separate from it: one family cannot run here because it needs the workflow event payload, the other because it needs a workflow value, and folding them would print one count for two omissions.
  • Renderings: a Value-bearing argv — matched by path, and NOT runnable here heading in the human output (below the blank line the published harvest stops at, so it cannot reach a harvest of the pasted block), a notRunnable field on the --json matched row, a stated omission on --commands stderr, and its own term in the reconciliation.

The usage line needed nothing.--commands promises "one runnable command per line on stdout, nothing else", and that promise is now more true, not less — the entries this adds are the ones it must not carry. PR #15105's --changed --commands line is untouched.

3. The nine entries, before and after

Read from discoverFamilies() at 369da918 and at e98bfc87.

Before (bare key CI never runs)After
scripts/check-adr-0087-registration.mjs… --base "$MERGE_BASE" [pr-automation.yml] NOT RUNNABLE · … --base "$SNAPSHOT_SHA" [cut-rc.yml] NOT RUNNABLE
scripts/check-changeset-no-major.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-empty-changeset.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-engine-split-ratio.mjsnode scripts/check-engine-split-ratio.mjs --days 90runnable, in --commands
scripts/check-test-completeness.mjs… "$RUNNER_TEMP/test-core.log" --scheduled … --package-list …NOT RUNNABLE · … "$RUNNER_TEMP/dogfood.log"NOT RUNNABLE
scripts/check-shard-attestation.mjsfive entries, one per ci.yml invocation, all NOT RUNNABLE (pnpm check:shard-attestation in lint.yml is a separate family and stays runnable)
scripts/check-required-contexts.mjsnode scripts/check-required-contexts.mjs --verify-required-setrunnable, in --commands
scripts/check-cross-package-test-inputs.mjs… --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"NOT RUNNABLE (pnpm check:cross-package-test-inputs stays runnable)
scripts/check-prerelease-pin-watch.mjsnode scripts/check-prerelease-pin-watch.mjs --verboserunnable, in --commands
scripts/pm/check-half-states.mjs (tenth member)… --format=markdown --provenance="$PROVENANCE"NOT RUNNABLE
scripts/check-release-section-coverage.mjs (bare; the --strict run had no entry)bare key kept (lint.yml runs it bare) plus… --strictrunnable, in --commands

The price, measured the way PR #15081 priced its own

Over the tracked tree, before to after:

check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every family present in both derivations keeps
its exact workflow set and its exact hint count

Verified as a set difference, not as a count: the only keys removed are the ten bare ones above, and every key added is one of the rendered invocations.

Live rendering, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack .github/workflows/ci.yml

Before, in the pasted block (four commands CI never runs):

 - node scripts/check-cross-package-test-inputs.mjs [ci.yml] matched via …
- node scripts/check-required-contexts.mjs [required-set-patrol.yml] matched via …
- node scripts/check-shard-attestation.mjs [ci.yml] matched via …
- node scripts/check-test-completeness.mjs [ci.yml] matched via …
Reconciliation — 40 famil(ies)

After — one of them promoted into the pasted block as the invocation CI really runs, the other eight under their own heading:

 - node scripts/check-required-contexts.mjs --verify-required-set [required-set-patrol.yml] matched via …
Value-bearing argv — matched by path, and NOT runnable here (8 famil(ies)):
- node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $RUNNER_TEMP
- node scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: ${{ matrix.shard }}, $RUNNER_TEMP
… 6 more
⇒ Printed as CI spells it, variable names in the value positions, so nothing above reads as a command to paste: these are ⛔ NOT in --commands, and a BARE run of any of these scripts is an invocation CI never makes.
⇒ Read from the workflow text, not from a table in this script: a token carrying ${{ … }} or a shell expansion is a variable, everything else is a literal and renders in full under the heading above.
Reconciliation — 37 famil(ies)
+ 8 famil(ies) this card's paths reach take a VALUE FROM THE WORKFLOW and sit OUTSIDE this total — …

--commands on those paths: 37 lines on stdout, zero containing a workflow variable, with the 8 stated on stderr. --json carries notRunnable on all 8 matched rows and none of their commands appear in commands.

4. Self-test — 1338 cases pass

Cases added (all PASS):

CaseWhat it pins
an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gonethe variable kind, quoted from pr-automation.yml
…and it carries the variable it takes from the workflow$MERGE_BASE reaches the row label
an invocation CONTINUED across two lines renders as ONE command, values and allthe ci.yml attestation, three physical lines
…with BOTH of its workflow variables named, the expression and the shell expansion${{ matrix.shard }} and $RUNNER_TEMP
…and ⛔ no bare key survives beside itthe bare key is gone, not merely joined
an invocation whose every token is a LITERAL renders as the command CI runs, value included--days 90, --verify-required-set, --verbose
…and every one of them is runnable — no variable, so nothing to labelthe literal kind stays in --commands
…and none of the three keeps a bare key CI never runsthe control in the other direction
⛔ no derived key is a TRUNCATED argvno continuation backslash, no redirection fd as an argument
renderedArgv renders every tail and reports which values come from the workflowthe classifier directly
argvTokens holds a quoted value and a ${{ … }} expression togetherthe tokenizer
joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENTthe join and its shell-correct refusal
the live tree really carries N value-bearing invocation(s) across M script(s)the class is live, count read not typed
the card's named specimens all classify as VARIABLE from the workflow textno per-script table was needed
…and the three the card called value-bearing that are really LITERAL render as runnable commands insteadthe card's own table corrected by measurement
⛔ no live key survives as a bare path for a script CI only ever invokes WITH argvthe defect, closed on the live tree
⛔ and no live key is truncatedacross all live workflows
a script CI invokes bare AND with argv keeps BOTH entriesthe third clause, with check-release-section-coverage as the live specimen
…each under the argv that file really runs it with, merge base and allthe keyed half of the live pr-automation cases

Cases rewritten — six, and each is named because "existing cases keep their verdicts" deserves an honest accounting. All six still PASS and each keeps its subject:

  • Two pinned the refusal this card retires (an invocation whose tail carries a VALUE keeps the bare path key, an invocation CONTINUED onto the next line keeps the bare key). Same fixture, same tail — the expectation moved from "keeps the bare key" to "renders in full and says whether it is runnable", because that bare key is the defect.
  • renderableArgv keeps a complete flag run and refuses everything else became renderedArgv renders every tail and reports which values come from the workflow — the function it pins was replaced.
  • every derived key is either the bare script path or that path plus a complete flag run became …plus the WHOLE argv, re-tokenising to itself — the same "never a truncated argv" property, restated for a renderer.
  • Three live cases (the live Check Changeset job discovers its ADR-0087 gate and its two siblings) asserted discovery by bare key; they now assert it by script, which is what their own comment says they are about, with a new case beside them pinning the keyed half so a rewrite of the step cannot satisfy them vacuously.
  • One pending-changeset case counted node scripts/check-empty-changeset.mjs exactly once; it now counts node scripts/check-empty-changeset.mjs --base "$MERGE_BASE", which is the family that section is about.

Cases untouched and still passing: everything else, including the two census cases PR #15081 added and the redirection case (a complete flag run followed by a redirection IS keyed).

5. scripts/pm/bare-root-worklist.mjs — the pre-authorised second file

node scripts/pm/bare-root-worklist.mjs --self-test reddened exactly as the dispatch anticipated, with 3 STALE and 6 FRESH rows, all for check-adr-0087-registration's re-keying. Its own printed remedy was followed and nothing else in the file was touched: the three rows keyed on the retired bare path are deleted, and six second-key twins recorded in their place (one per workflow invocation), verdict SPELLABLE-UNDECLARED and spelling carried whole from the retired row, with no count restated — this pass measured none, which is that file's own rule.

Green after: OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.

6. Gates — every one derived for this diff, at e98bfc87

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the commit. Exit code captured by redirecting first, never across a pipe; each row quotes the gate's own verdict line.

Gateexitverdict line
node scripts/check-ci-filter-parity.mjs0OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg…
node scripts/check-closing-keyword-parity.mjs0check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords…, all registered).
node scripts/check-closing-keyword-parity.mjs --self-test0✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs0✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 44.4s
node scripts/check-self-test-wired.mjs0✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test0check-self-test-wired --self-test: 4 live ledger row(s) verified… every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs0✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s)…
node scripts/check-whole-set-label-write.mjs --self-test0✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 69 live row(s)… none stale, none missing, none contradicted.
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 437 file(s)…
pnpm check:bash32-floor0✓ check-bash32-floor: 26 tracked shell file(s)… name no bash 4+ construct…
pnpm check:cli-command-ids0✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s)… all resolve
pnpm check:cross-package-test-inputs0OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live0✓ check:declared-population-live — 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8230 tracked file(s).
pnpm check:entry-guard0✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 8223 text file(s)… no raw ASCII control bytes).
pnpm check:parse-guard0✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1338 cases pass.
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 142/181 --filter occurrence(s) across 33 file(s) resolve…
pnpm check:refd-timer-probe0OK check-refd-timer-probe: 5848 source file(s) swept…
pnpm check:watch-hint-literal0✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s)… no unrostered spelling of the idiom in the tree.
pnpm lint (repo-wide eslint . --no-inline-config)0clean run, no output — run in full, not narrowed (60s under the shared verify lock)

The two long runs went through scripts/pm/os-verify-lock.sh: pm-dispatch-gatesVERDICT command-exit 0 · held the lock 250s, lintVERDICT command-exit 0 · held the lock 60s.

Two notes on scope of measurement, so nothing reads as verified that is not:

  • check-test-completeness is NOT MEASURED here, in its own words — it is not in this diff's derived gate list, and a bare run of it prints PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. (exit 3). That refusal is now the reason it leaves --commands, not a gate this PR skipped.
  • No vitest suite exercises either edited script.git grep for dispatch-gates / bare-root-worklist across *.test.* returns five files, and all five hits are prose comments citing dispatch-gates.mjs's own self-test as the thing that pins their property. That self-test is pnpm check:pm-dispatch-gates above.

7. The design call, on the four axes

The triage left one design choice open: how to render an invocation whose values are workflow variables. Options were (A) classify each token from the workflow text — literals render, variables render with the variable named and the entry marked not runnable; (B) a per-script declaration table of which flags are value-bearing; (C) keep the bare key.

  • Real business need. Measured, not supposed: fifteen live invocations across nine scripts, ten with a tenth found at head. One bare key returns a non-verdict (PREREQUISITE NOT MET, exit 3), four run a different program than CI runs, three answer against a different base. The consumer is real — the PM's dispatch brief and --ran both read this list. (C) keeps a measured defect; (B) buys nothing (A) does not, at the cost of a declaration surface.
  • Long-term soundness. (A) reads the workflow, which is the single source of what CI runs; it cannot drift, because the thing it reads is the thing it describes. (B) is a second source that must be kept in sync by hand — a de-facto second contract, which is what contract-first refuses. No workaround: the continuation and the redirection fd are fixed at the parse layer where they belong, not patched around at the classifier.
  • Making it structurally harder for an AI to get wrong. The rejected direction is the one that reads best: emitting the variable-bearing command into --commands with the variable unset. It runs, and it answers a question CI never asked — the tolerant-fallback shape, one layer up. (A) refuses loudly instead: the entry is named, its variables are named, and it is kept out of the list whose caption promises runnable. A missing lead, never a fabricated one.
  • Startup focus / no capability spread. (A) adds no flag, no output mode, no declaration surface and no configuration. It reuses the ciOnly channel's exact shape for a second measurement. (B) would have added a per-script table that grows with the workflow corpus forever.

Recommendation taken: (A). The triage's ⛔ on (B) is discharged by the measurement in section 1 rather than by assertion — every one of the fifteen invocations classifies from the workflow text alone.

Bounded scope

scripts/pm/dispatch-gates.mjs plus the pre-authorised verdict rows in scripts/pm/bare-root-worklist.mjs. No workflow edits, no .md, no other script. skip-changeset: nothing is published from these paths. Not a governed surface — draft PR for the seat's contract-tier review.


Generated by Claude Code

…back to a bare key
`dispatch-gates` keyed the argv half of `(script, args)` only when the tail was
a complete run of flag-shaped tokens. Every other invocation kept a BARE path
key — and for nine scripts that bare key is an invocation CI never makes.
Measured on this tree: `node scripts/check-test-completeness.mjs` exits 3 with
`PREREQUISITE NOT MET`; `check-required-contexts` runs the static pin check
while `required-set-patrol.yml` runs `--verify-required-set`; the three
`--base` gates answer against their default base while CI pins the merge base
or the cut snapshot.
Each argv token is now classified from the workflow text as a LITERAL (verbatim
in the workflow, same on every run) or a VARIABLE (`${{ … }}`, a shell
expansion, a step output). An all-literal invocation renders in full and enters
`--commands`; one carrying a variable renders with the variable's own name in
the value position, is marked NOT RUNNABLE LOCALLY on its own labelled line
under its own heading, and is kept out of `--commands`, out of the pasted block
and out of the published harvest — the same treatment `ciOnly` already gets,
reached by a different measurement. No per-script declaration table: the split
reads off the workflow for all fifteen live invocations.
Two mechanisms make the classification possible. `joinLineContinuations`
splices a continued command into one line before any matcher runs, so the tail
is the WHOLE argv rather than the first physical line of it (comments are never
joined — a `#` runs to the newline, so its backslash is comment text).
`tailBeforeRedirection` drops a redirection's file descriptor, which `2>&1` had
been leaving on the argv side of the boundary.
Measured, before -> after over the tracked tree:
check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every surviving family keeps its exact
workflow set and hint count
Beyond the nine the card named, the same sweep at this commit finds
`scripts/pm/check-half-states.mjs` (a tenth member: `half-state-patrol.yml`
runs it `--format=markdown --provenance="$PROVENANCE"` and nowhere else), and
`check-release-section-coverage.mjs --strict` — a second invocation in
`release-coverage-patrol.yml` that had no entry of its own because a
continuation hid it.
Self-test: 1338 cases pass. Two cases that pinned the retired refusal are
rewritten on the same fixtures to the new expectation; four that named a bare
key now name the script or the keyed invocation. New cases pin one literal
kind, one variable kind, the bare-invocation control, the tokenizer, the
continuation join and the live class read from the real workflows.
`scripts/pm/bare-root-worklist.mjs` records the verdict rows this re-keying
owes, per that script's own printed remedy: three rows for the retired bare
`check-adr-0087-registration` key are deleted and six second-key twins recorded
in their place, one per workflow invocation. Verdict and spelling carried
whole; no count restated, because this pass measured none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Side effect worth a triage read: the standing finding in #14899

That card measures node scripts/pm/check-half-states.mjs being derived into the per-PR gate family for any diff carrying a changeset — the scheduled live board sweep, which CI never runs on a PR, and which sat for 3m09s on a dev container blocking the rest of the derived list behind it.

This PR's classifier reaches it as the tenth member of the value-bearing class, so the behaviour that card measures changes. Measured on this branch at e98bfc87, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands .changeset/EXAMPLE.md:

pnpm check:pm-half-states [still there: the offline self-test lint.yml runs]
(no `node scripts/pm/check-half-states.mjs` line at all)

and in the human rendering it now appears once, under the new heading, as what half-state-patrol.yml really runs:

Value-bearing argv — matched by path, and NOT runnable here:
- node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" [half-state-patrol.yml] matched via .changeset/EXAMPLE.md ⇢ gate source '.changeset'
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $PROVENANCE

Two boundaries on that claim, stated so nothing here reads as more verified than it is:

  • The after state is measured on this branch. The before state is taken from that card's own quoted output plus this branch's baseline family reading at 369da918, where the family exists under the bare key scripts/pm/check-half-states.mjs with no not-runnable classification and therefore inside --commands. I did not re-run the base tool against that path.
  • ⛔ This PR makes no claim on that card and carries no closing keyword for it. It was filed against a defect that spans more than argv rendering (the routing question — a scheduled sweep reached through a .changeset watch hint — is untouched here), and its disposition is triage's, not this branch's.

Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 4, 2026 00:39
@os-steve
os-steve added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f594e70Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15083-value-bearing-argv branch September 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@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

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key - #15114

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv
Sep 4, 2026
Merged

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key#15114
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#15083

dispatch-gates keys the derivation on (script, args) and, since PR #15081, admitted the argv half only when the tail was a complete run of flag-shaped tokens. Nine scripts whose only CI invocations carry a value or a line continuation therefore kept a bare path key CI never runs bare. This renders those invocations instead, classifying each argv token from the workflow text.

Verification head sha: e98bfc87. Every gate below and every number in this body was run or read at that commit.

1. The measurement — every CI invocation of the nine, token by token

Read from .github/workflows/*.yml at origin/main (369da918). A literal appears verbatim in the workflow and is the same on every run; a variable is a GitHub expression, a shell expansion, a step output, or a path an earlier step produced.

ScriptWorkflow · job / stepInvocation as CI runs itValue tokensClass
check-adr-0087-registrationpr-automation.yml · "Require an ADR-0087 disposition…"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASE (env from steps.diffbase.outputs.merge_base)VARIABLE
cut-rc.yml · "Gate — this cut introduces no unregistered breaking change"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-changeset-no-majorpr-automation.yml · "…no major bump" (one-liner)--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no major bump"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-empty-changesetpr-automation.yml · "…no empty-frontmatter changeset"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no empty-frontmatter changeset"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-engine-split-ratiolint.yml · shallow-history guard collector--self-testnoneLITERAL
engine-split-metric.yml · "Compute ratio (90-day window)"--days 9090 written in the workflowLITERAL
check-test-completenessci.yml · Test Core "Test completeness guard""$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" (continued over 3 lines)$RUNNER_TEMPVARIABLE
ci.yml · Dogfood "Test completeness guard""$RUNNER_TEMP/dogfood.log"$RUNNER_TEMPVARIABLE
check-shard-attestationci.yml · test "Attest this shard ran and passed"--emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMP (test, 6 are literals)VARIABLE
ci.yml · dogfood "Attest this shard ran and passed"--emit --job dogfood --shard ${{ matrix.shard }} --total 3 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMPVARIABLE
ci.yml · dogfood-verify "Attest this leg ran and passed"--emit --job dogfood-verify --shard 1 --total 1 --out "$RUNNER_TEMP/shard-attestation"$RUNNER_TEMP only (1, 1 are literals)VARIABLE
ci.yml · test-gate "Verify test shard results"--verify --gate 'Test Core' --dir "$OS_ATTEST_DIR" --filter-result "$OS_FILTER_RESULT" --download-outcome "$OS_DOWNLOAD_OUTCOME" --leg "test/6:$OS_TEST_RESULT"4 env vars ('Test Core' is a literal)VARIABLE
ci.yml · dogfood-gate "Verify dogfood shard results"--verify --gate 'Dogfood Regression Gate' --dir … --leg "dogfood/3:$OS_DOGFOOD_RESULT" --leg "dogfood-verify/1:$OS_VERIFY_RESULT"5 env varsVARIABLE
check-required-contextsrequired-set-patrol.yml · "Run the live required-set sweep"--verify-required-set (continued, then two redirections)none — the continuation carries only redirectionsLITERAL
check-cross-package-test-inputsci.yml · filter job, affected-set union--union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt" (continued over 3 lines)$RUNNER_TEMPVARIABLE
check-prerelease-pin-watchprerelease-pin-watch.yml · "Self-test the probe"--self-testnoneLITERAL
prerelease-pin-watch.yml · "Probe npm for a stable release"--verbose then 2 redirected to 1none — the 2 was the redirection's own file descriptorLITERAL

Three of the nine are not value-bearing at all.check-engine-split-ratio --days 90 writes its value down; check-required-contexts --verify-required-set and check-prerelease-pin-watch --verbose have complete argv that were refused only because a continuation and a redirection file descriptor stood behind them. Those three now render as runnable commands.

The split reads off the workflow text for all fifteen invocations, so the triage's ⛔ holds: no per-script declaration table is owed.

Two members the card's table does not name

Re-measured at origin/main rather than taken from the card:

  • scripts/pm/check-half-states.mjs is a tenth member of the same class.half-state-patrol.yml runs it --format=markdown --provenance="$PROVENANCE" (continued, then redirected) and nowhere else, where PROVENANCE is a workflow env built from github.run_id / github.sha / github.event_name. It derived under a bare key too.
  • check-release-section-coverage.mjs --strict is a second invocation in release-coverage-patrol.yml that had no entry of its own — a continuation hid it, and the script's bare key (which lint.yml really does run) absorbed it. It is now its own runnable family, and the bare key stays where CI runs it bare.

What the bare keys actually did, run at origin/main

This is why the bare key is not a neutral omission:

Bare invocationexitfirst line
node scripts/check-test-completeness.mjs3PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.
node scripts/check-required-contexts.mjs0runs the static pin check — required-set-patrol.yml runs the live--verify-required-set sweep
node scripts/check-shard-attestation.mjs0duplicates what pnpm check:shard-attestation (a separate family) already runs; ci.yml's --emit/--verify runs are a different program
node scripts/check-cross-package-test-inputs.mjs0same shape — pnpm check:cross-package-test-inputs already covers it
the three --base gates0diff against their default base, while CI pins the merge base or the cut snapshot
node scripts/check-engine-split-ratio.mjs2shallow-clone refusal in this container; engine-split-metric.yml checks out with fetch-depth: 0

2. What changed in scripts/pm/dispatch-gates.mjs

  • joinLineContinuations splices a continued command into one line before any matcher runs, so the captured tail is the whole argv rather than the first physical line of it. ⛔ A comment line is never joined — a # comment runs to the newline, so its trailing backslash is comment text (measured: 107 continuation lines inside run: bodies on this tree, zero of them comments).
  • tailBeforeRedirection drops a redirection's file descriptor. DIRECT_CHECK_INVOCATION correctly stops the tail before the redirection operator, but 2 redirected to 1 puts its fd on the argv side of that boundary, so --verbose captured as --verbose 2. Discriminator is the shell's own: digits touching the operator are a descriptor, digits with a space before it are an argument.
  • argvTokens is a quote-aware tokenizer that also holds a ${{ … }} expression together — both spellings are live in ci.yml (--gate 'Test Core', --shard ${{ matrix.shard }}) and a bare whitespace split would classify their pieces separately.
  • renderedArgv replaces renderableArgv. It renders every tail and returns the workflow values it carries, so the caller decides what to do rather than the classifier refusing.
  • entry.notRunnable is the second not-runnable-here classification beside entry.ciOnly, and is deliberately kept separate from it: one family cannot run here because it needs the workflow event payload, the other because it needs a workflow value, and folding them would print one count for two omissions.
  • Renderings: a Value-bearing argv — matched by path, and NOT runnable here heading in the human output (below the blank line the published harvest stops at, so it cannot reach a harvest of the pasted block), a notRunnable field on the --json matched row, a stated omission on --commands stderr, and its own term in the reconciliation.

The usage line needed nothing.--commands promises "one runnable command per line on stdout, nothing else", and that promise is now more true, not less — the entries this adds are the ones it must not carry. PR #15105's --changed --commands line is untouched.

3. The nine entries, before and after

Read from discoverFamilies() at 369da918 and at e98bfc87.

Before (bare key CI never runs)After
scripts/check-adr-0087-registration.mjs… --base "$MERGE_BASE" [pr-automation.yml] NOT RUNNABLE · … --base "$SNAPSHOT_SHA" [cut-rc.yml] NOT RUNNABLE
scripts/check-changeset-no-major.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-empty-changeset.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-engine-split-ratio.mjsnode scripts/check-engine-split-ratio.mjs --days 90runnable, in --commands
scripts/check-test-completeness.mjs… "$RUNNER_TEMP/test-core.log" --scheduled … --package-list …NOT RUNNABLE · … "$RUNNER_TEMP/dogfood.log"NOT RUNNABLE
scripts/check-shard-attestation.mjsfive entries, one per ci.yml invocation, all NOT RUNNABLE (pnpm check:shard-attestation in lint.yml is a separate family and stays runnable)
scripts/check-required-contexts.mjsnode scripts/check-required-contexts.mjs --verify-required-setrunnable, in --commands
scripts/check-cross-package-test-inputs.mjs… --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"NOT RUNNABLE (pnpm check:cross-package-test-inputs stays runnable)
scripts/check-prerelease-pin-watch.mjsnode scripts/check-prerelease-pin-watch.mjs --verboserunnable, in --commands
scripts/pm/check-half-states.mjs (tenth member)… --format=markdown --provenance="$PROVENANCE"NOT RUNNABLE
scripts/check-release-section-coverage.mjs (bare; the --strict run had no entry)bare key kept (lint.yml runs it bare) plus… --strictrunnable, in --commands

The price, measured the way PR #15081 priced its own

Over the tracked tree, before to after:

check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every family present in both derivations keeps
its exact workflow set and its exact hint count

Verified as a set difference, not as a count: the only keys removed are the ten bare ones above, and every key added is one of the rendered invocations.

Live rendering, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack .github/workflows/ci.yml

Before, in the pasted block (four commands CI never runs):

 - node scripts/check-cross-package-test-inputs.mjs [ci.yml] matched via …
- node scripts/check-required-contexts.mjs [required-set-patrol.yml] matched via …
- node scripts/check-shard-attestation.mjs [ci.yml] matched via …
- node scripts/check-test-completeness.mjs [ci.yml] matched via …
Reconciliation — 40 famil(ies)

After — one of them promoted into the pasted block as the invocation CI really runs, the other eight under their own heading:

 - node scripts/check-required-contexts.mjs --verify-required-set [required-set-patrol.yml] matched via …
Value-bearing argv — matched by path, and NOT runnable here (8 famil(ies)):
- node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $RUNNER_TEMP
- node scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: ${{ matrix.shard }}, $RUNNER_TEMP
… 6 more
⇒ Printed as CI spells it, variable names in the value positions, so nothing above reads as a command to paste: these are ⛔ NOT in --commands, and a BARE run of any of these scripts is an invocation CI never makes.
⇒ Read from the workflow text, not from a table in this script: a token carrying ${{ … }} or a shell expansion is a variable, everything else is a literal and renders in full under the heading above.
Reconciliation — 37 famil(ies)
+ 8 famil(ies) this card's paths reach take a VALUE FROM THE WORKFLOW and sit OUTSIDE this total — …

--commands on those paths: 37 lines on stdout, zero containing a workflow variable, with the 8 stated on stderr. --json carries notRunnable on all 8 matched rows and none of their commands appear in commands.

4. Self-test — 1338 cases pass

Cases added (all PASS):

CaseWhat it pins
an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gonethe variable kind, quoted from pr-automation.yml
…and it carries the variable it takes from the workflow$MERGE_BASE reaches the row label
an invocation CONTINUED across two lines renders as ONE command, values and allthe ci.yml attestation, three physical lines
…with BOTH of its workflow variables named, the expression and the shell expansion${{ matrix.shard }} and $RUNNER_TEMP
…and ⛔ no bare key survives beside itthe bare key is gone, not merely joined
an invocation whose every token is a LITERAL renders as the command CI runs, value included--days 90, --verify-required-set, --verbose
…and every one of them is runnable — no variable, so nothing to labelthe literal kind stays in --commands
…and none of the three keeps a bare key CI never runsthe control in the other direction
⛔ no derived key is a TRUNCATED argvno continuation backslash, no redirection fd as an argument
renderedArgv renders every tail and reports which values come from the workflowthe classifier directly
argvTokens holds a quoted value and a ${{ … }} expression togetherthe tokenizer
joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENTthe join and its shell-correct refusal
the live tree really carries N value-bearing invocation(s) across M script(s)the class is live, count read not typed
the card's named specimens all classify as VARIABLE from the workflow textno per-script table was needed
…and the three the card called value-bearing that are really LITERAL render as runnable commands insteadthe card's own table corrected by measurement
⛔ no live key survives as a bare path for a script CI only ever invokes WITH argvthe defect, closed on the live tree
⛔ and no live key is truncatedacross all live workflows
a script CI invokes bare AND with argv keeps BOTH entriesthe third clause, with check-release-section-coverage as the live specimen
…each under the argv that file really runs it with, merge base and allthe keyed half of the live pr-automation cases

Cases rewritten — six, and each is named because "existing cases keep their verdicts" deserves an honest accounting. All six still PASS and each keeps its subject:

  • Two pinned the refusal this card retires (an invocation whose tail carries a VALUE keeps the bare path key, an invocation CONTINUED onto the next line keeps the bare key). Same fixture, same tail — the expectation moved from "keeps the bare key" to "renders in full and says whether it is runnable", because that bare key is the defect.
  • renderableArgv keeps a complete flag run and refuses everything else became renderedArgv renders every tail and reports which values come from the workflow — the function it pins was replaced.
  • every derived key is either the bare script path or that path plus a complete flag run became …plus the WHOLE argv, re-tokenising to itself — the same "never a truncated argv" property, restated for a renderer.
  • Three live cases (the live Check Changeset job discovers its ADR-0087 gate and its two siblings) asserted discovery by bare key; they now assert it by script, which is what their own comment says they are about, with a new case beside them pinning the keyed half so a rewrite of the step cannot satisfy them vacuously.
  • One pending-changeset case counted node scripts/check-empty-changeset.mjs exactly once; it now counts node scripts/check-empty-changeset.mjs --base "$MERGE_BASE", which is the family that section is about.

Cases untouched and still passing: everything else, including the two census cases PR #15081 added and the redirection case (a complete flag run followed by a redirection IS keyed).

5. scripts/pm/bare-root-worklist.mjs — the pre-authorised second file

node scripts/pm/bare-root-worklist.mjs --self-test reddened exactly as the dispatch anticipated, with 3 STALE and 6 FRESH rows, all for check-adr-0087-registration's re-keying. Its own printed remedy was followed and nothing else in the file was touched: the three rows keyed on the retired bare path are deleted, and six second-key twins recorded in their place (one per workflow invocation), verdict SPELLABLE-UNDECLARED and spelling carried whole from the retired row, with no count restated — this pass measured none, which is that file's own rule.

Green after: OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.

6. Gates — every one derived for this diff, at e98bfc87

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the commit. Exit code captured by redirecting first, never across a pipe; each row quotes the gate's own verdict line.

Gateexitverdict line
node scripts/check-ci-filter-parity.mjs0OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg…
node scripts/check-closing-keyword-parity.mjs0check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords…, all registered).
node scripts/check-closing-keyword-parity.mjs --self-test0✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs0✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 44.4s
node scripts/check-self-test-wired.mjs0✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test0check-self-test-wired --self-test: 4 live ledger row(s) verified… every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs0✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s)…
node scripts/check-whole-set-label-write.mjs --self-test0✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 69 live row(s)… none stale, none missing, none contradicted.
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 437 file(s)…
pnpm check:bash32-floor0✓ check-bash32-floor: 26 tracked shell file(s)… name no bash 4+ construct…
pnpm check:cli-command-ids0✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s)… all resolve
pnpm check:cross-package-test-inputs0OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live0✓ check:declared-population-live — 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8230 tracked file(s).
pnpm check:entry-guard0✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 8223 text file(s)… no raw ASCII control bytes).
pnpm check:parse-guard0✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1338 cases pass.
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 142/181 --filter occurrence(s) across 33 file(s) resolve…
pnpm check:refd-timer-probe0OK check-refd-timer-probe: 5848 source file(s) swept…
pnpm check:watch-hint-literal0✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s)… no unrostered spelling of the idiom in the tree.
pnpm lint (repo-wide eslint . --no-inline-config)0clean run, no output — run in full, not narrowed (60s under the shared verify lock)

The two long runs went through scripts/pm/os-verify-lock.sh: pm-dispatch-gatesVERDICT command-exit 0 · held the lock 250s, lintVERDICT command-exit 0 · held the lock 60s.

Two notes on scope of measurement, so nothing reads as verified that is not:

  • check-test-completeness is NOT MEASURED here, in its own words — it is not in this diff's derived gate list, and a bare run of it prints PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. (exit 3). That refusal is now the reason it leaves --commands, not a gate this PR skipped.
  • No vitest suite exercises either edited script.git grep for dispatch-gates / bare-root-worklist across *.test.* returns five files, and all five hits are prose comments citing dispatch-gates.mjs's own self-test as the thing that pins their property. That self-test is pnpm check:pm-dispatch-gates above.

7. The design call, on the four axes

The triage left one design choice open: how to render an invocation whose values are workflow variables. Options were (A) classify each token from the workflow text — literals render, variables render with the variable named and the entry marked not runnable; (B) a per-script declaration table of which flags are value-bearing; (C) keep the bare key.

  • Real business need. Measured, not supposed: fifteen live invocations across nine scripts, ten with a tenth found at head. One bare key returns a non-verdict (PREREQUISITE NOT MET, exit 3), four run a different program than CI runs, three answer against a different base. The consumer is real — the PM's dispatch brief and --ran both read this list. (C) keeps a measured defect; (B) buys nothing (A) does not, at the cost of a declaration surface.
  • Long-term soundness. (A) reads the workflow, which is the single source of what CI runs; it cannot drift, because the thing it reads is the thing it describes. (B) is a second source that must be kept in sync by hand — a de-facto second contract, which is what contract-first refuses. No workaround: the continuation and the redirection fd are fixed at the parse layer where they belong, not patched around at the classifier.
  • Making it structurally harder for an AI to get wrong. The rejected direction is the one that reads best: emitting the variable-bearing command into --commands with the variable unset. It runs, and it answers a question CI never asked — the tolerant-fallback shape, one layer up. (A) refuses loudly instead: the entry is named, its variables are named, and it is kept out of the list whose caption promises runnable. A missing lead, never a fabricated one.
  • Startup focus / no capability spread. (A) adds no flag, no output mode, no declaration surface and no configuration. It reuses the ciOnly channel's exact shape for a second measurement. (B) would have added a per-script table that grows with the workflow corpus forever.

Recommendation taken: (A). The triage's ⛔ on (B) is discharged by the measurement in section 1 rather than by assertion — every one of the fifteen invocations classifies from the workflow text alone.

Bounded scope

scripts/pm/dispatch-gates.mjs plus the pre-authorised verdict rows in scripts/pm/bare-root-worklist.mjs. No workflow edits, no .md, no other script. skip-changeset: nothing is published from these paths. Not a governed surface — draft PR for the seat's contract-tier review.


Generated by Claude Code

…back to a bare key
`dispatch-gates` keyed the argv half of `(script, args)` only when the tail was
a complete run of flag-shaped tokens. Every other invocation kept a BARE path
key — and for nine scripts that bare key is an invocation CI never makes.
Measured on this tree: `node scripts/check-test-completeness.mjs` exits 3 with
`PREREQUISITE NOT MET`; `check-required-contexts` runs the static pin check
while `required-set-patrol.yml` runs `--verify-required-set`; the three
`--base` gates answer against their default base while CI pins the merge base
or the cut snapshot.
Each argv token is now classified from the workflow text as a LITERAL (verbatim
in the workflow, same on every run) or a VARIABLE (`${{ … }}`, a shell
expansion, a step output). An all-literal invocation renders in full and enters
`--commands`; one carrying a variable renders with the variable's own name in
the value position, is marked NOT RUNNABLE LOCALLY on its own labelled line
under its own heading, and is kept out of `--commands`, out of the pasted block
and out of the published harvest — the same treatment `ciOnly` already gets,
reached by a different measurement. No per-script declaration table: the split
reads off the workflow for all fifteen live invocations.
Two mechanisms make the classification possible. `joinLineContinuations`
splices a continued command into one line before any matcher runs, so the tail
is the WHOLE argv rather than the first physical line of it (comments are never
joined — a `#` runs to the newline, so its backslash is comment text).
`tailBeforeRedirection` drops a redirection's file descriptor, which `2>&1` had
been leaving on the argv side of the boundary.
Measured, before -> after over the tracked tree:
check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every surviving family keeps its exact
workflow set and hint count
Beyond the nine the card named, the same sweep at this commit finds
`scripts/pm/check-half-states.mjs` (a tenth member: `half-state-patrol.yml`
runs it `--format=markdown --provenance="$PROVENANCE"` and nowhere else), and
`check-release-section-coverage.mjs --strict` — a second invocation in
`release-coverage-patrol.yml` that had no entry of its own because a
continuation hid it.
Self-test: 1338 cases pass. Two cases that pinned the retired refusal are
rewritten on the same fixtures to the new expectation; four that named a bare
key now name the script or the keyed invocation. New cases pin one literal
kind, one variable kind, the bare-invocation control, the tokenizer, the
continuation join and the live class read from the real workflows.
`scripts/pm/bare-root-worklist.mjs` records the verdict rows this re-keying
owes, per that script's own printed remedy: three rows for the retired bare
`check-adr-0087-registration` key are deleted and six second-key twins recorded
in their place, one per workflow invocation. Verdict and spelling carried
whole; no count restated, because this pass measured none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Side effect worth a triage read: the standing finding in #14899

That card measures node scripts/pm/check-half-states.mjs being derived into the per-PR gate family for any diff carrying a changeset — the scheduled live board sweep, which CI never runs on a PR, and which sat for 3m09s on a dev container blocking the rest of the derived list behind it.

This PR's classifier reaches it as the tenth member of the value-bearing class, so the behaviour that card measures changes. Measured on this branch at e98bfc87, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands .changeset/EXAMPLE.md:

pnpm check:pm-half-states [still there: the offline self-test lint.yml runs]
(no `node scripts/pm/check-half-states.mjs` line at all)

and in the human rendering it now appears once, under the new heading, as what half-state-patrol.yml really runs:

Value-bearing argv — matched by path, and NOT runnable here:
- node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" [half-state-patrol.yml] matched via .changeset/EXAMPLE.md ⇢ gate source '.changeset'
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $PROVENANCE

Two boundaries on that claim, stated so nothing here reads as more verified than it is:

  • The after state is measured on this branch. The before state is taken from that card's own quoted output plus this branch's baseline family reading at 369da918, where the family exists under the bare key scripts/pm/check-half-states.mjs with no not-runnable classification and therefore inside --commands. I did not re-run the base tool against that path.
  • ⛔ This PR makes no claim on that card and carries no closing keyword for it. It was filed against a defect that spans more than argv rendering (the routing question — a scheduled sweep reached through a .changeset watch hint — is untouched here), and its disposition is triage's, not this branch's.

Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 4, 2026 00:39
@os-steve
os-steve added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f594e70Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15083-value-bearing-argv branch September 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@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

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key - #15114

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv
Sep 4, 2026
Merged

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key#15114
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#15083

dispatch-gates keys the derivation on (script, args) and, since PR #15081, admitted the argv half only when the tail was a complete run of flag-shaped tokens. Nine scripts whose only CI invocations carry a value or a line continuation therefore kept a bare path key CI never runs bare. This renders those invocations instead, classifying each argv token from the workflow text.

Verification head sha: e98bfc87. Every gate below and every number in this body was run or read at that commit.

1. The measurement — every CI invocation of the nine, token by token

Read from .github/workflows/*.yml at origin/main (369da918). A literal appears verbatim in the workflow and is the same on every run; a variable is a GitHub expression, a shell expansion, a step output, or a path an earlier step produced.

ScriptWorkflow · job / stepInvocation as CI runs itValue tokensClass
check-adr-0087-registrationpr-automation.yml · "Require an ADR-0087 disposition…"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASE (env from steps.diffbase.outputs.merge_base)VARIABLE
cut-rc.yml · "Gate — this cut introduces no unregistered breaking change"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-changeset-no-majorpr-automation.yml · "…no major bump" (one-liner)--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no major bump"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-empty-changesetpr-automation.yml · "…no empty-frontmatter changeset"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no empty-frontmatter changeset"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-engine-split-ratiolint.yml · shallow-history guard collector--self-testnoneLITERAL
engine-split-metric.yml · "Compute ratio (90-day window)"--days 9090 written in the workflowLITERAL
check-test-completenessci.yml · Test Core "Test completeness guard""$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" (continued over 3 lines)$RUNNER_TEMPVARIABLE
ci.yml · Dogfood "Test completeness guard""$RUNNER_TEMP/dogfood.log"$RUNNER_TEMPVARIABLE
check-shard-attestationci.yml · test "Attest this shard ran and passed"--emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMP (test, 6 are literals)VARIABLE
ci.yml · dogfood "Attest this shard ran and passed"--emit --job dogfood --shard ${{ matrix.shard }} --total 3 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMPVARIABLE
ci.yml · dogfood-verify "Attest this leg ran and passed"--emit --job dogfood-verify --shard 1 --total 1 --out "$RUNNER_TEMP/shard-attestation"$RUNNER_TEMP only (1, 1 are literals)VARIABLE
ci.yml · test-gate "Verify test shard results"--verify --gate 'Test Core' --dir "$OS_ATTEST_DIR" --filter-result "$OS_FILTER_RESULT" --download-outcome "$OS_DOWNLOAD_OUTCOME" --leg "test/6:$OS_TEST_RESULT"4 env vars ('Test Core' is a literal)VARIABLE
ci.yml · dogfood-gate "Verify dogfood shard results"--verify --gate 'Dogfood Regression Gate' --dir … --leg "dogfood/3:$OS_DOGFOOD_RESULT" --leg "dogfood-verify/1:$OS_VERIFY_RESULT"5 env varsVARIABLE
check-required-contextsrequired-set-patrol.yml · "Run the live required-set sweep"--verify-required-set (continued, then two redirections)none — the continuation carries only redirectionsLITERAL
check-cross-package-test-inputsci.yml · filter job, affected-set union--union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt" (continued over 3 lines)$RUNNER_TEMPVARIABLE
check-prerelease-pin-watchprerelease-pin-watch.yml · "Self-test the probe"--self-testnoneLITERAL
prerelease-pin-watch.yml · "Probe npm for a stable release"--verbose then 2 redirected to 1none — the 2 was the redirection's own file descriptorLITERAL

Three of the nine are not value-bearing at all.check-engine-split-ratio --days 90 writes its value down; check-required-contexts --verify-required-set and check-prerelease-pin-watch --verbose have complete argv that were refused only because a continuation and a redirection file descriptor stood behind them. Those three now render as runnable commands.

The split reads off the workflow text for all fifteen invocations, so the triage's ⛔ holds: no per-script declaration table is owed.

Two members the card's table does not name

Re-measured at origin/main rather than taken from the card:

  • scripts/pm/check-half-states.mjs is a tenth member of the same class.half-state-patrol.yml runs it --format=markdown --provenance="$PROVENANCE" (continued, then redirected) and nowhere else, where PROVENANCE is a workflow env built from github.run_id / github.sha / github.event_name. It derived under a bare key too.
  • check-release-section-coverage.mjs --strict is a second invocation in release-coverage-patrol.yml that had no entry of its own — a continuation hid it, and the script's bare key (which lint.yml really does run) absorbed it. It is now its own runnable family, and the bare key stays where CI runs it bare.

What the bare keys actually did, run at origin/main

This is why the bare key is not a neutral omission:

Bare invocationexitfirst line
node scripts/check-test-completeness.mjs3PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.
node scripts/check-required-contexts.mjs0runs the static pin check — required-set-patrol.yml runs the live--verify-required-set sweep
node scripts/check-shard-attestation.mjs0duplicates what pnpm check:shard-attestation (a separate family) already runs; ci.yml's --emit/--verify runs are a different program
node scripts/check-cross-package-test-inputs.mjs0same shape — pnpm check:cross-package-test-inputs already covers it
the three --base gates0diff against their default base, while CI pins the merge base or the cut snapshot
node scripts/check-engine-split-ratio.mjs2shallow-clone refusal in this container; engine-split-metric.yml checks out with fetch-depth: 0

2. What changed in scripts/pm/dispatch-gates.mjs

  • joinLineContinuations splices a continued command into one line before any matcher runs, so the captured tail is the whole argv rather than the first physical line of it. ⛔ A comment line is never joined — a # comment runs to the newline, so its trailing backslash is comment text (measured: 107 continuation lines inside run: bodies on this tree, zero of them comments).
  • tailBeforeRedirection drops a redirection's file descriptor. DIRECT_CHECK_INVOCATION correctly stops the tail before the redirection operator, but 2 redirected to 1 puts its fd on the argv side of that boundary, so --verbose captured as --verbose 2. Discriminator is the shell's own: digits touching the operator are a descriptor, digits with a space before it are an argument.
  • argvTokens is a quote-aware tokenizer that also holds a ${{ … }} expression together — both spellings are live in ci.yml (--gate 'Test Core', --shard ${{ matrix.shard }}) and a bare whitespace split would classify their pieces separately.
  • renderedArgv replaces renderableArgv. It renders every tail and returns the workflow values it carries, so the caller decides what to do rather than the classifier refusing.
  • entry.notRunnable is the second not-runnable-here classification beside entry.ciOnly, and is deliberately kept separate from it: one family cannot run here because it needs the workflow event payload, the other because it needs a workflow value, and folding them would print one count for two omissions.
  • Renderings: a Value-bearing argv — matched by path, and NOT runnable here heading in the human output (below the blank line the published harvest stops at, so it cannot reach a harvest of the pasted block), a notRunnable field on the --json matched row, a stated omission on --commands stderr, and its own term in the reconciliation.

The usage line needed nothing.--commands promises "one runnable command per line on stdout, nothing else", and that promise is now more true, not less — the entries this adds are the ones it must not carry. PR #15105's --changed --commands line is untouched.

3. The nine entries, before and after

Read from discoverFamilies() at 369da918 and at e98bfc87.

Before (bare key CI never runs)After
scripts/check-adr-0087-registration.mjs… --base "$MERGE_BASE" [pr-automation.yml] NOT RUNNABLE · … --base "$SNAPSHOT_SHA" [cut-rc.yml] NOT RUNNABLE
scripts/check-changeset-no-major.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-empty-changeset.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-engine-split-ratio.mjsnode scripts/check-engine-split-ratio.mjs --days 90runnable, in --commands
scripts/check-test-completeness.mjs… "$RUNNER_TEMP/test-core.log" --scheduled … --package-list …NOT RUNNABLE · … "$RUNNER_TEMP/dogfood.log"NOT RUNNABLE
scripts/check-shard-attestation.mjsfive entries, one per ci.yml invocation, all NOT RUNNABLE (pnpm check:shard-attestation in lint.yml is a separate family and stays runnable)
scripts/check-required-contexts.mjsnode scripts/check-required-contexts.mjs --verify-required-setrunnable, in --commands
scripts/check-cross-package-test-inputs.mjs… --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"NOT RUNNABLE (pnpm check:cross-package-test-inputs stays runnable)
scripts/check-prerelease-pin-watch.mjsnode scripts/check-prerelease-pin-watch.mjs --verboserunnable, in --commands
scripts/pm/check-half-states.mjs (tenth member)… --format=markdown --provenance="$PROVENANCE"NOT RUNNABLE
scripts/check-release-section-coverage.mjs (bare; the --strict run had no entry)bare key kept (lint.yml runs it bare) plus… --strictrunnable, in --commands

The price, measured the way PR #15081 priced its own

Over the tracked tree, before to after:

check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every family present in both derivations keeps
its exact workflow set and its exact hint count

Verified as a set difference, not as a count: the only keys removed are the ten bare ones above, and every key added is one of the rendered invocations.

Live rendering, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack .github/workflows/ci.yml

Before, in the pasted block (four commands CI never runs):

 - node scripts/check-cross-package-test-inputs.mjs [ci.yml] matched via …
- node scripts/check-required-contexts.mjs [required-set-patrol.yml] matched via …
- node scripts/check-shard-attestation.mjs [ci.yml] matched via …
- node scripts/check-test-completeness.mjs [ci.yml] matched via …
Reconciliation — 40 famil(ies)

After — one of them promoted into the pasted block as the invocation CI really runs, the other eight under their own heading:

 - node scripts/check-required-contexts.mjs --verify-required-set [required-set-patrol.yml] matched via …
Value-bearing argv — matched by path, and NOT runnable here (8 famil(ies)):
- node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $RUNNER_TEMP
- node scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: ${{ matrix.shard }}, $RUNNER_TEMP
… 6 more
⇒ Printed as CI spells it, variable names in the value positions, so nothing above reads as a command to paste: these are ⛔ NOT in --commands, and a BARE run of any of these scripts is an invocation CI never makes.
⇒ Read from the workflow text, not from a table in this script: a token carrying ${{ … }} or a shell expansion is a variable, everything else is a literal and renders in full under the heading above.
Reconciliation — 37 famil(ies)
+ 8 famil(ies) this card's paths reach take a VALUE FROM THE WORKFLOW and sit OUTSIDE this total — …

--commands on those paths: 37 lines on stdout, zero containing a workflow variable, with the 8 stated on stderr. --json carries notRunnable on all 8 matched rows and none of their commands appear in commands.

4. Self-test — 1338 cases pass

Cases added (all PASS):

CaseWhat it pins
an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gonethe variable kind, quoted from pr-automation.yml
…and it carries the variable it takes from the workflow$MERGE_BASE reaches the row label
an invocation CONTINUED across two lines renders as ONE command, values and allthe ci.yml attestation, three physical lines
…with BOTH of its workflow variables named, the expression and the shell expansion${{ matrix.shard }} and $RUNNER_TEMP
…and ⛔ no bare key survives beside itthe bare key is gone, not merely joined
an invocation whose every token is a LITERAL renders as the command CI runs, value included--days 90, --verify-required-set, --verbose
…and every one of them is runnable — no variable, so nothing to labelthe literal kind stays in --commands
…and none of the three keeps a bare key CI never runsthe control in the other direction
⛔ no derived key is a TRUNCATED argvno continuation backslash, no redirection fd as an argument
renderedArgv renders every tail and reports which values come from the workflowthe classifier directly
argvTokens holds a quoted value and a ${{ … }} expression togetherthe tokenizer
joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENTthe join and its shell-correct refusal
the live tree really carries N value-bearing invocation(s) across M script(s)the class is live, count read not typed
the card's named specimens all classify as VARIABLE from the workflow textno per-script table was needed
…and the three the card called value-bearing that are really LITERAL render as runnable commands insteadthe card's own table corrected by measurement
⛔ no live key survives as a bare path for a script CI only ever invokes WITH argvthe defect, closed on the live tree
⛔ and no live key is truncatedacross all live workflows
a script CI invokes bare AND with argv keeps BOTH entriesthe third clause, with check-release-section-coverage as the live specimen
…each under the argv that file really runs it with, merge base and allthe keyed half of the live pr-automation cases

Cases rewritten — six, and each is named because "existing cases keep their verdicts" deserves an honest accounting. All six still PASS and each keeps its subject:

  • Two pinned the refusal this card retires (an invocation whose tail carries a VALUE keeps the bare path key, an invocation CONTINUED onto the next line keeps the bare key). Same fixture, same tail — the expectation moved from "keeps the bare key" to "renders in full and says whether it is runnable", because that bare key is the defect.
  • renderableArgv keeps a complete flag run and refuses everything else became renderedArgv renders every tail and reports which values come from the workflow — the function it pins was replaced.
  • every derived key is either the bare script path or that path plus a complete flag run became …plus the WHOLE argv, re-tokenising to itself — the same "never a truncated argv" property, restated for a renderer.
  • Three live cases (the live Check Changeset job discovers its ADR-0087 gate and its two siblings) asserted discovery by bare key; they now assert it by script, which is what their own comment says they are about, with a new case beside them pinning the keyed half so a rewrite of the step cannot satisfy them vacuously.
  • One pending-changeset case counted node scripts/check-empty-changeset.mjs exactly once; it now counts node scripts/check-empty-changeset.mjs --base "$MERGE_BASE", which is the family that section is about.

Cases untouched and still passing: everything else, including the two census cases PR #15081 added and the redirection case (a complete flag run followed by a redirection IS keyed).

5. scripts/pm/bare-root-worklist.mjs — the pre-authorised second file

node scripts/pm/bare-root-worklist.mjs --self-test reddened exactly as the dispatch anticipated, with 3 STALE and 6 FRESH rows, all for check-adr-0087-registration's re-keying. Its own printed remedy was followed and nothing else in the file was touched: the three rows keyed on the retired bare path are deleted, and six second-key twins recorded in their place (one per workflow invocation), verdict SPELLABLE-UNDECLARED and spelling carried whole from the retired row, with no count restated — this pass measured none, which is that file's own rule.

Green after: OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.

6. Gates — every one derived for this diff, at e98bfc87

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the commit. Exit code captured by redirecting first, never across a pipe; each row quotes the gate's own verdict line.

Gateexitverdict line
node scripts/check-ci-filter-parity.mjs0OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg…
node scripts/check-closing-keyword-parity.mjs0check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords…, all registered).
node scripts/check-closing-keyword-parity.mjs --self-test0✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs0✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 44.4s
node scripts/check-self-test-wired.mjs0✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test0check-self-test-wired --self-test: 4 live ledger row(s) verified… every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs0✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s)…
node scripts/check-whole-set-label-write.mjs --self-test0✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 69 live row(s)… none stale, none missing, none contradicted.
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 437 file(s)…
pnpm check:bash32-floor0✓ check-bash32-floor: 26 tracked shell file(s)… name no bash 4+ construct…
pnpm check:cli-command-ids0✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s)… all resolve
pnpm check:cross-package-test-inputs0OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live0✓ check:declared-population-live — 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8230 tracked file(s).
pnpm check:entry-guard0✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 8223 text file(s)… no raw ASCII control bytes).
pnpm check:parse-guard0✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1338 cases pass.
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 142/181 --filter occurrence(s) across 33 file(s) resolve…
pnpm check:refd-timer-probe0OK check-refd-timer-probe: 5848 source file(s) swept…
pnpm check:watch-hint-literal0✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s)… no unrostered spelling of the idiom in the tree.
pnpm lint (repo-wide eslint . --no-inline-config)0clean run, no output — run in full, not narrowed (60s under the shared verify lock)

The two long runs went through scripts/pm/os-verify-lock.sh: pm-dispatch-gatesVERDICT command-exit 0 · held the lock 250s, lintVERDICT command-exit 0 · held the lock 60s.

Two notes on scope of measurement, so nothing reads as verified that is not:

  • check-test-completeness is NOT MEASURED here, in its own words — it is not in this diff's derived gate list, and a bare run of it prints PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. (exit 3). That refusal is now the reason it leaves --commands, not a gate this PR skipped.
  • No vitest suite exercises either edited script.git grep for dispatch-gates / bare-root-worklist across *.test.* returns five files, and all five hits are prose comments citing dispatch-gates.mjs's own self-test as the thing that pins their property. That self-test is pnpm check:pm-dispatch-gates above.

7. The design call, on the four axes

The triage left one design choice open: how to render an invocation whose values are workflow variables. Options were (A) classify each token from the workflow text — literals render, variables render with the variable named and the entry marked not runnable; (B) a per-script declaration table of which flags are value-bearing; (C) keep the bare key.

  • Real business need. Measured, not supposed: fifteen live invocations across nine scripts, ten with a tenth found at head. One bare key returns a non-verdict (PREREQUISITE NOT MET, exit 3), four run a different program than CI runs, three answer against a different base. The consumer is real — the PM's dispatch brief and --ran both read this list. (C) keeps a measured defect; (B) buys nothing (A) does not, at the cost of a declaration surface.
  • Long-term soundness. (A) reads the workflow, which is the single source of what CI runs; it cannot drift, because the thing it reads is the thing it describes. (B) is a second source that must be kept in sync by hand — a de-facto second contract, which is what contract-first refuses. No workaround: the continuation and the redirection fd are fixed at the parse layer where they belong, not patched around at the classifier.
  • Making it structurally harder for an AI to get wrong. The rejected direction is the one that reads best: emitting the variable-bearing command into --commands with the variable unset. It runs, and it answers a question CI never asked — the tolerant-fallback shape, one layer up. (A) refuses loudly instead: the entry is named, its variables are named, and it is kept out of the list whose caption promises runnable. A missing lead, never a fabricated one.
  • Startup focus / no capability spread. (A) adds no flag, no output mode, no declaration surface and no configuration. It reuses the ciOnly channel's exact shape for a second measurement. (B) would have added a per-script table that grows with the workflow corpus forever.

Recommendation taken: (A). The triage's ⛔ on (B) is discharged by the measurement in section 1 rather than by assertion — every one of the fifteen invocations classifies from the workflow text alone.

Bounded scope

scripts/pm/dispatch-gates.mjs plus the pre-authorised verdict rows in scripts/pm/bare-root-worklist.mjs. No workflow edits, no .md, no other script. skip-changeset: nothing is published from these paths. Not a governed surface — draft PR for the seat's contract-tier review.


Generated by Claude Code

…back to a bare key
`dispatch-gates` keyed the argv half of `(script, args)` only when the tail was
a complete run of flag-shaped tokens. Every other invocation kept a BARE path
key — and for nine scripts that bare key is an invocation CI never makes.
Measured on this tree: `node scripts/check-test-completeness.mjs` exits 3 with
`PREREQUISITE NOT MET`; `check-required-contexts` runs the static pin check
while `required-set-patrol.yml` runs `--verify-required-set`; the three
`--base` gates answer against their default base while CI pins the merge base
or the cut snapshot.
Each argv token is now classified from the workflow text as a LITERAL (verbatim
in the workflow, same on every run) or a VARIABLE (`${{ … }}`, a shell
expansion, a step output). An all-literal invocation renders in full and enters
`--commands`; one carrying a variable renders with the variable's own name in
the value position, is marked NOT RUNNABLE LOCALLY on its own labelled line
under its own heading, and is kept out of `--commands`, out of the pasted block
and out of the published harvest — the same treatment `ciOnly` already gets,
reached by a different measurement. No per-script declaration table: the split
reads off the workflow for all fifteen live invocations.
Two mechanisms make the classification possible. `joinLineContinuations`
splices a continued command into one line before any matcher runs, so the tail
is the WHOLE argv rather than the first physical line of it (comments are never
joined — a `#` runs to the newline, so its backslash is comment text).
`tailBeforeRedirection` drops a redirection's file descriptor, which `2>&1` had
been leaving on the argv side of the boundary.
Measured, before -> after over the tracked tree:
check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every surviving family keeps its exact
workflow set and hint count
Beyond the nine the card named, the same sweep at this commit finds
`scripts/pm/check-half-states.mjs` (a tenth member: `half-state-patrol.yml`
runs it `--format=markdown --provenance="$PROVENANCE"` and nowhere else), and
`check-release-section-coverage.mjs --strict` — a second invocation in
`release-coverage-patrol.yml` that had no entry of its own because a
continuation hid it.
Self-test: 1338 cases pass. Two cases that pinned the retired refusal are
rewritten on the same fixtures to the new expectation; four that named a bare
key now name the script or the keyed invocation. New cases pin one literal
kind, one variable kind, the bare-invocation control, the tokenizer, the
continuation join and the live class read from the real workflows.
`scripts/pm/bare-root-worklist.mjs` records the verdict rows this re-keying
owes, per that script's own printed remedy: three rows for the retired bare
`check-adr-0087-registration` key are deleted and six second-key twins recorded
in their place, one per workflow invocation. Verdict and spelling carried
whole; no count restated, because this pass measured none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Side effect worth a triage read: the standing finding in #14899

That card measures node scripts/pm/check-half-states.mjs being derived into the per-PR gate family for any diff carrying a changeset — the scheduled live board sweep, which CI never runs on a PR, and which sat for 3m09s on a dev container blocking the rest of the derived list behind it.

This PR's classifier reaches it as the tenth member of the value-bearing class, so the behaviour that card measures changes. Measured on this branch at e98bfc87, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands .changeset/EXAMPLE.md:

pnpm check:pm-half-states [still there: the offline self-test lint.yml runs]
(no `node scripts/pm/check-half-states.mjs` line at all)

and in the human rendering it now appears once, under the new heading, as what half-state-patrol.yml really runs:

Value-bearing argv — matched by path, and NOT runnable here:
- node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" [half-state-patrol.yml] matched via .changeset/EXAMPLE.md ⇢ gate source '.changeset'
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $PROVENANCE

Two boundaries on that claim, stated so nothing here reads as more verified than it is:

  • The after state is measured on this branch. The before state is taken from that card's own quoted output plus this branch's baseline family reading at 369da918, where the family exists under the bare key scripts/pm/check-half-states.mjs with no not-runnable classification and therefore inside --commands. I did not re-run the base tool against that path.
  • ⛔ This PR makes no claim on that card and carries no closing keyword for it. It was filed against a defect that spans more than argv rendering (the routing question — a scheduled sweep reached through a .changeset watch hint — is untouched here), and its disposition is triage's, not this branch's.

Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 4, 2026 00:39
@os-steve
os-steve added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f594e70Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15083-value-bearing-argv branch September 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@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

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key - #15114

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv
Sep 4, 2026
Merged

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key#15114
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#15083

dispatch-gates keys the derivation on (script, args) and, since PR #15081, admitted the argv half only when the tail was a complete run of flag-shaped tokens. Nine scripts whose only CI invocations carry a value or a line continuation therefore kept a bare path key CI never runs bare. This renders those invocations instead, classifying each argv token from the workflow text.

Verification head sha: e98bfc87. Every gate below and every number in this body was run or read at that commit.

1. The measurement — every CI invocation of the nine, token by token

Read from .github/workflows/*.yml at origin/main (369da918). A literal appears verbatim in the workflow and is the same on every run; a variable is a GitHub expression, a shell expansion, a step output, or a path an earlier step produced.

ScriptWorkflow · job / stepInvocation as CI runs itValue tokensClass
check-adr-0087-registrationpr-automation.yml · "Require an ADR-0087 disposition…"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASE (env from steps.diffbase.outputs.merge_base)VARIABLE
cut-rc.yml · "Gate — this cut introduces no unregistered breaking change"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-changeset-no-majorpr-automation.yml · "…no major bump" (one-liner)--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no major bump"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-empty-changesetpr-automation.yml · "…no empty-frontmatter changeset"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no empty-frontmatter changeset"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-engine-split-ratiolint.yml · shallow-history guard collector--self-testnoneLITERAL
engine-split-metric.yml · "Compute ratio (90-day window)"--days 9090 written in the workflowLITERAL
check-test-completenessci.yml · Test Core "Test completeness guard""$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" (continued over 3 lines)$RUNNER_TEMPVARIABLE
ci.yml · Dogfood "Test completeness guard""$RUNNER_TEMP/dogfood.log"$RUNNER_TEMPVARIABLE
check-shard-attestationci.yml · test "Attest this shard ran and passed"--emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMP (test, 6 are literals)VARIABLE
ci.yml · dogfood "Attest this shard ran and passed"--emit --job dogfood --shard ${{ matrix.shard }} --total 3 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMPVARIABLE
ci.yml · dogfood-verify "Attest this leg ran and passed"--emit --job dogfood-verify --shard 1 --total 1 --out "$RUNNER_TEMP/shard-attestation"$RUNNER_TEMP only (1, 1 are literals)VARIABLE
ci.yml · test-gate "Verify test shard results"--verify --gate 'Test Core' --dir "$OS_ATTEST_DIR" --filter-result "$OS_FILTER_RESULT" --download-outcome "$OS_DOWNLOAD_OUTCOME" --leg "test/6:$OS_TEST_RESULT"4 env vars ('Test Core' is a literal)VARIABLE
ci.yml · dogfood-gate "Verify dogfood shard results"--verify --gate 'Dogfood Regression Gate' --dir … --leg "dogfood/3:$OS_DOGFOOD_RESULT" --leg "dogfood-verify/1:$OS_VERIFY_RESULT"5 env varsVARIABLE
check-required-contextsrequired-set-patrol.yml · "Run the live required-set sweep"--verify-required-set (continued, then two redirections)none — the continuation carries only redirectionsLITERAL
check-cross-package-test-inputsci.yml · filter job, affected-set union--union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt" (continued over 3 lines)$RUNNER_TEMPVARIABLE
check-prerelease-pin-watchprerelease-pin-watch.yml · "Self-test the probe"--self-testnoneLITERAL
prerelease-pin-watch.yml · "Probe npm for a stable release"--verbose then 2 redirected to 1none — the 2 was the redirection's own file descriptorLITERAL

Three of the nine are not value-bearing at all.check-engine-split-ratio --days 90 writes its value down; check-required-contexts --verify-required-set and check-prerelease-pin-watch --verbose have complete argv that were refused only because a continuation and a redirection file descriptor stood behind them. Those three now render as runnable commands.

The split reads off the workflow text for all fifteen invocations, so the triage's ⛔ holds: no per-script declaration table is owed.

Two members the card's table does not name

Re-measured at origin/main rather than taken from the card:

  • scripts/pm/check-half-states.mjs is a tenth member of the same class.half-state-patrol.yml runs it --format=markdown --provenance="$PROVENANCE" (continued, then redirected) and nowhere else, where PROVENANCE is a workflow env built from github.run_id / github.sha / github.event_name. It derived under a bare key too.
  • check-release-section-coverage.mjs --strict is a second invocation in release-coverage-patrol.yml that had no entry of its own — a continuation hid it, and the script's bare key (which lint.yml really does run) absorbed it. It is now its own runnable family, and the bare key stays where CI runs it bare.

What the bare keys actually did, run at origin/main

This is why the bare key is not a neutral omission:

Bare invocationexitfirst line
node scripts/check-test-completeness.mjs3PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.
node scripts/check-required-contexts.mjs0runs the static pin check — required-set-patrol.yml runs the live--verify-required-set sweep
node scripts/check-shard-attestation.mjs0duplicates what pnpm check:shard-attestation (a separate family) already runs; ci.yml's --emit/--verify runs are a different program
node scripts/check-cross-package-test-inputs.mjs0same shape — pnpm check:cross-package-test-inputs already covers it
the three --base gates0diff against their default base, while CI pins the merge base or the cut snapshot
node scripts/check-engine-split-ratio.mjs2shallow-clone refusal in this container; engine-split-metric.yml checks out with fetch-depth: 0

2. What changed in scripts/pm/dispatch-gates.mjs

  • joinLineContinuations splices a continued command into one line before any matcher runs, so the captured tail is the whole argv rather than the first physical line of it. ⛔ A comment line is never joined — a # comment runs to the newline, so its trailing backslash is comment text (measured: 107 continuation lines inside run: bodies on this tree, zero of them comments).
  • tailBeforeRedirection drops a redirection's file descriptor. DIRECT_CHECK_INVOCATION correctly stops the tail before the redirection operator, but 2 redirected to 1 puts its fd on the argv side of that boundary, so --verbose captured as --verbose 2. Discriminator is the shell's own: digits touching the operator are a descriptor, digits with a space before it are an argument.
  • argvTokens is a quote-aware tokenizer that also holds a ${{ … }} expression together — both spellings are live in ci.yml (--gate 'Test Core', --shard ${{ matrix.shard }}) and a bare whitespace split would classify their pieces separately.
  • renderedArgv replaces renderableArgv. It renders every tail and returns the workflow values it carries, so the caller decides what to do rather than the classifier refusing.
  • entry.notRunnable is the second not-runnable-here classification beside entry.ciOnly, and is deliberately kept separate from it: one family cannot run here because it needs the workflow event payload, the other because it needs a workflow value, and folding them would print one count for two omissions.
  • Renderings: a Value-bearing argv — matched by path, and NOT runnable here heading in the human output (below the blank line the published harvest stops at, so it cannot reach a harvest of the pasted block), a notRunnable field on the --json matched row, a stated omission on --commands stderr, and its own term in the reconciliation.

The usage line needed nothing.--commands promises "one runnable command per line on stdout, nothing else", and that promise is now more true, not less — the entries this adds are the ones it must not carry. PR #15105's --changed --commands line is untouched.

3. The nine entries, before and after

Read from discoverFamilies() at 369da918 and at e98bfc87.

Before (bare key CI never runs)After
scripts/check-adr-0087-registration.mjs… --base "$MERGE_BASE" [pr-automation.yml] NOT RUNNABLE · … --base "$SNAPSHOT_SHA" [cut-rc.yml] NOT RUNNABLE
scripts/check-changeset-no-major.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-empty-changeset.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-engine-split-ratio.mjsnode scripts/check-engine-split-ratio.mjs --days 90runnable, in --commands
scripts/check-test-completeness.mjs… "$RUNNER_TEMP/test-core.log" --scheduled … --package-list …NOT RUNNABLE · … "$RUNNER_TEMP/dogfood.log"NOT RUNNABLE
scripts/check-shard-attestation.mjsfive entries, one per ci.yml invocation, all NOT RUNNABLE (pnpm check:shard-attestation in lint.yml is a separate family and stays runnable)
scripts/check-required-contexts.mjsnode scripts/check-required-contexts.mjs --verify-required-setrunnable, in --commands
scripts/check-cross-package-test-inputs.mjs… --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"NOT RUNNABLE (pnpm check:cross-package-test-inputs stays runnable)
scripts/check-prerelease-pin-watch.mjsnode scripts/check-prerelease-pin-watch.mjs --verboserunnable, in --commands
scripts/pm/check-half-states.mjs (tenth member)… --format=markdown --provenance="$PROVENANCE"NOT RUNNABLE
scripts/check-release-section-coverage.mjs (bare; the --strict run had no entry)bare key kept (lint.yml runs it bare) plus… --strictrunnable, in --commands

The price, measured the way PR #15081 priced its own

Over the tracked tree, before to after:

check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every family present in both derivations keeps
its exact workflow set and its exact hint count

Verified as a set difference, not as a count: the only keys removed are the ten bare ones above, and every key added is one of the rendered invocations.

Live rendering, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack .github/workflows/ci.yml

Before, in the pasted block (four commands CI never runs):

 - node scripts/check-cross-package-test-inputs.mjs [ci.yml] matched via …
- node scripts/check-required-contexts.mjs [required-set-patrol.yml] matched via …
- node scripts/check-shard-attestation.mjs [ci.yml] matched via …
- node scripts/check-test-completeness.mjs [ci.yml] matched via …
Reconciliation — 40 famil(ies)

After — one of them promoted into the pasted block as the invocation CI really runs, the other eight under their own heading:

 - node scripts/check-required-contexts.mjs --verify-required-set [required-set-patrol.yml] matched via …
Value-bearing argv — matched by path, and NOT runnable here (8 famil(ies)):
- node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $RUNNER_TEMP
- node scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: ${{ matrix.shard }}, $RUNNER_TEMP
… 6 more
⇒ Printed as CI spells it, variable names in the value positions, so nothing above reads as a command to paste: these are ⛔ NOT in --commands, and a BARE run of any of these scripts is an invocation CI never makes.
⇒ Read from the workflow text, not from a table in this script: a token carrying ${{ … }} or a shell expansion is a variable, everything else is a literal and renders in full under the heading above.
Reconciliation — 37 famil(ies)
+ 8 famil(ies) this card's paths reach take a VALUE FROM THE WORKFLOW and sit OUTSIDE this total — …

--commands on those paths: 37 lines on stdout, zero containing a workflow variable, with the 8 stated on stderr. --json carries notRunnable on all 8 matched rows and none of their commands appear in commands.

4. Self-test — 1338 cases pass

Cases added (all PASS):

CaseWhat it pins
an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gonethe variable kind, quoted from pr-automation.yml
…and it carries the variable it takes from the workflow$MERGE_BASE reaches the row label
an invocation CONTINUED across two lines renders as ONE command, values and allthe ci.yml attestation, three physical lines
…with BOTH of its workflow variables named, the expression and the shell expansion${{ matrix.shard }} and $RUNNER_TEMP
…and ⛔ no bare key survives beside itthe bare key is gone, not merely joined
an invocation whose every token is a LITERAL renders as the command CI runs, value included--days 90, --verify-required-set, --verbose
…and every one of them is runnable — no variable, so nothing to labelthe literal kind stays in --commands
…and none of the three keeps a bare key CI never runsthe control in the other direction
⛔ no derived key is a TRUNCATED argvno continuation backslash, no redirection fd as an argument
renderedArgv renders every tail and reports which values come from the workflowthe classifier directly
argvTokens holds a quoted value and a ${{ … }} expression togetherthe tokenizer
joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENTthe join and its shell-correct refusal
the live tree really carries N value-bearing invocation(s) across M script(s)the class is live, count read not typed
the card's named specimens all classify as VARIABLE from the workflow textno per-script table was needed
…and the three the card called value-bearing that are really LITERAL render as runnable commands insteadthe card's own table corrected by measurement
⛔ no live key survives as a bare path for a script CI only ever invokes WITH argvthe defect, closed on the live tree
⛔ and no live key is truncatedacross all live workflows
a script CI invokes bare AND with argv keeps BOTH entriesthe third clause, with check-release-section-coverage as the live specimen
…each under the argv that file really runs it with, merge base and allthe keyed half of the live pr-automation cases

Cases rewritten — six, and each is named because "existing cases keep their verdicts" deserves an honest accounting. All six still PASS and each keeps its subject:

  • Two pinned the refusal this card retires (an invocation whose tail carries a VALUE keeps the bare path key, an invocation CONTINUED onto the next line keeps the bare key). Same fixture, same tail — the expectation moved from "keeps the bare key" to "renders in full and says whether it is runnable", because that bare key is the defect.
  • renderableArgv keeps a complete flag run and refuses everything else became renderedArgv renders every tail and reports which values come from the workflow — the function it pins was replaced.
  • every derived key is either the bare script path or that path plus a complete flag run became …plus the WHOLE argv, re-tokenising to itself — the same "never a truncated argv" property, restated for a renderer.
  • Three live cases (the live Check Changeset job discovers its ADR-0087 gate and its two siblings) asserted discovery by bare key; they now assert it by script, which is what their own comment says they are about, with a new case beside them pinning the keyed half so a rewrite of the step cannot satisfy them vacuously.
  • One pending-changeset case counted node scripts/check-empty-changeset.mjs exactly once; it now counts node scripts/check-empty-changeset.mjs --base "$MERGE_BASE", which is the family that section is about.

Cases untouched and still passing: everything else, including the two census cases PR #15081 added and the redirection case (a complete flag run followed by a redirection IS keyed).

5. scripts/pm/bare-root-worklist.mjs — the pre-authorised second file

node scripts/pm/bare-root-worklist.mjs --self-test reddened exactly as the dispatch anticipated, with 3 STALE and 6 FRESH rows, all for check-adr-0087-registration's re-keying. Its own printed remedy was followed and nothing else in the file was touched: the three rows keyed on the retired bare path are deleted, and six second-key twins recorded in their place (one per workflow invocation), verdict SPELLABLE-UNDECLARED and spelling carried whole from the retired row, with no count restated — this pass measured none, which is that file's own rule.

Green after: OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.

6. Gates — every one derived for this diff, at e98bfc87

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the commit. Exit code captured by redirecting first, never across a pipe; each row quotes the gate's own verdict line.

Gateexitverdict line
node scripts/check-ci-filter-parity.mjs0OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg…
node scripts/check-closing-keyword-parity.mjs0check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords…, all registered).
node scripts/check-closing-keyword-parity.mjs --self-test0✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs0✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 44.4s
node scripts/check-self-test-wired.mjs0✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test0check-self-test-wired --self-test: 4 live ledger row(s) verified… every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs0✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s)…
node scripts/check-whole-set-label-write.mjs --self-test0✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 69 live row(s)… none stale, none missing, none contradicted.
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 437 file(s)…
pnpm check:bash32-floor0✓ check-bash32-floor: 26 tracked shell file(s)… name no bash 4+ construct…
pnpm check:cli-command-ids0✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s)… all resolve
pnpm check:cross-package-test-inputs0OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live0✓ check:declared-population-live — 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8230 tracked file(s).
pnpm check:entry-guard0✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 8223 text file(s)… no raw ASCII control bytes).
pnpm check:parse-guard0✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1338 cases pass.
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 142/181 --filter occurrence(s) across 33 file(s) resolve…
pnpm check:refd-timer-probe0OK check-refd-timer-probe: 5848 source file(s) swept…
pnpm check:watch-hint-literal0✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s)… no unrostered spelling of the idiom in the tree.
pnpm lint (repo-wide eslint . --no-inline-config)0clean run, no output — run in full, not narrowed (60s under the shared verify lock)

The two long runs went through scripts/pm/os-verify-lock.sh: pm-dispatch-gatesVERDICT command-exit 0 · held the lock 250s, lintVERDICT command-exit 0 · held the lock 60s.

Two notes on scope of measurement, so nothing reads as verified that is not:

  • check-test-completeness is NOT MEASURED here, in its own words — it is not in this diff's derived gate list, and a bare run of it prints PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. (exit 3). That refusal is now the reason it leaves --commands, not a gate this PR skipped.
  • No vitest suite exercises either edited script.git grep for dispatch-gates / bare-root-worklist across *.test.* returns five files, and all five hits are prose comments citing dispatch-gates.mjs's own self-test as the thing that pins their property. That self-test is pnpm check:pm-dispatch-gates above.

7. The design call, on the four axes

The triage left one design choice open: how to render an invocation whose values are workflow variables. Options were (A) classify each token from the workflow text — literals render, variables render with the variable named and the entry marked not runnable; (B) a per-script declaration table of which flags are value-bearing; (C) keep the bare key.

  • Real business need. Measured, not supposed: fifteen live invocations across nine scripts, ten with a tenth found at head. One bare key returns a non-verdict (PREREQUISITE NOT MET, exit 3), four run a different program than CI runs, three answer against a different base. The consumer is real — the PM's dispatch brief and --ran both read this list. (C) keeps a measured defect; (B) buys nothing (A) does not, at the cost of a declaration surface.
  • Long-term soundness. (A) reads the workflow, which is the single source of what CI runs; it cannot drift, because the thing it reads is the thing it describes. (B) is a second source that must be kept in sync by hand — a de-facto second contract, which is what contract-first refuses. No workaround: the continuation and the redirection fd are fixed at the parse layer where they belong, not patched around at the classifier.
  • Making it structurally harder for an AI to get wrong. The rejected direction is the one that reads best: emitting the variable-bearing command into --commands with the variable unset. It runs, and it answers a question CI never asked — the tolerant-fallback shape, one layer up. (A) refuses loudly instead: the entry is named, its variables are named, and it is kept out of the list whose caption promises runnable. A missing lead, never a fabricated one.
  • Startup focus / no capability spread. (A) adds no flag, no output mode, no declaration surface and no configuration. It reuses the ciOnly channel's exact shape for a second measurement. (B) would have added a per-script table that grows with the workflow corpus forever.

Recommendation taken: (A). The triage's ⛔ on (B) is discharged by the measurement in section 1 rather than by assertion — every one of the fifteen invocations classifies from the workflow text alone.

Bounded scope

scripts/pm/dispatch-gates.mjs plus the pre-authorised verdict rows in scripts/pm/bare-root-worklist.mjs. No workflow edits, no .md, no other script. skip-changeset: nothing is published from these paths. Not a governed surface — draft PR for the seat's contract-tier review.


Generated by Claude Code

…back to a bare key
`dispatch-gates` keyed the argv half of `(script, args)` only when the tail was
a complete run of flag-shaped tokens. Every other invocation kept a BARE path
key — and for nine scripts that bare key is an invocation CI never makes.
Measured on this tree: `node scripts/check-test-completeness.mjs` exits 3 with
`PREREQUISITE NOT MET`; `check-required-contexts` runs the static pin check
while `required-set-patrol.yml` runs `--verify-required-set`; the three
`--base` gates answer against their default base while CI pins the merge base
or the cut snapshot.
Each argv token is now classified from the workflow text as a LITERAL (verbatim
in the workflow, same on every run) or a VARIABLE (`${{ … }}`, a shell
expansion, a step output). An all-literal invocation renders in full and enters
`--commands`; one carrying a variable renders with the variable's own name in
the value position, is marked NOT RUNNABLE LOCALLY on its own labelled line
under its own heading, and is kept out of `--commands`, out of the pasted block
and out of the published harvest — the same treatment `ciOnly` already gets,
reached by a different measurement. No per-script declaration table: the split
reads off the workflow for all fifteen live invocations.
Two mechanisms make the classification possible. `joinLineContinuations`
splices a continued command into one line before any matcher runs, so the tail
is the WHOLE argv rather than the first physical line of it (comments are never
joined — a `#` runs to the newline, so its backslash is comment text).
`tailBeforeRedirection` drops a redirection's file descriptor, which `2>&1` had
been leaving on the argv side of the boundary.
Measured, before -> after over the tracked tree:
check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every surviving family keeps its exact
workflow set and hint count
Beyond the nine the card named, the same sweep at this commit finds
`scripts/pm/check-half-states.mjs` (a tenth member: `half-state-patrol.yml`
runs it `--format=markdown --provenance="$PROVENANCE"` and nowhere else), and
`check-release-section-coverage.mjs --strict` — a second invocation in
`release-coverage-patrol.yml` that had no entry of its own because a
continuation hid it.
Self-test: 1338 cases pass. Two cases that pinned the retired refusal are
rewritten on the same fixtures to the new expectation; four that named a bare
key now name the script or the keyed invocation. New cases pin one literal
kind, one variable kind, the bare-invocation control, the tokenizer, the
continuation join and the live class read from the real workflows.
`scripts/pm/bare-root-worklist.mjs` records the verdict rows this re-keying
owes, per that script's own printed remedy: three rows for the retired bare
`check-adr-0087-registration` key are deleted and six second-key twins recorded
in their place, one per workflow invocation. Verdict and spelling carried
whole; no count restated, because this pass measured none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Side effect worth a triage read: the standing finding in #14899

That card measures node scripts/pm/check-half-states.mjs being derived into the per-PR gate family for any diff carrying a changeset — the scheduled live board sweep, which CI never runs on a PR, and which sat for 3m09s on a dev container blocking the rest of the derived list behind it.

This PR's classifier reaches it as the tenth member of the value-bearing class, so the behaviour that card measures changes. Measured on this branch at e98bfc87, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands .changeset/EXAMPLE.md:

pnpm check:pm-half-states [still there: the offline self-test lint.yml runs]
(no `node scripts/pm/check-half-states.mjs` line at all)

and in the human rendering it now appears once, under the new heading, as what half-state-patrol.yml really runs:

Value-bearing argv — matched by path, and NOT runnable here:
- node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" [half-state-patrol.yml] matched via .changeset/EXAMPLE.md ⇢ gate source '.changeset'
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $PROVENANCE

Two boundaries on that claim, stated so nothing here reads as more verified than it is:

  • The after state is measured on this branch. The before state is taken from that card's own quoted output plus this branch's baseline family reading at 369da918, where the family exists under the bare key scripts/pm/check-half-states.mjs with no not-runnable classification and therefore inside --commands. I did not re-run the base tool against that path.
  • ⛔ This PR makes no claim on that card and carries no closing keyword for it. It was filed against a defect that spans more than argv rendering (the routing question — a scheduled sweep reached through a .changeset watch hint — is untouched here), and its disposition is triage's, not this branch's.

Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 4, 2026 00:39
@os-steve
os-steve added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f594e70Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15083-value-bearing-argv branch September 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@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

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key - #15114

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv
Sep 4, 2026
Merged

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key#15114
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#15083

dispatch-gates keys the derivation on (script, args) and, since PR #15081, admitted the argv half only when the tail was a complete run of flag-shaped tokens. Nine scripts whose only CI invocations carry a value or a line continuation therefore kept a bare path key CI never runs bare. This renders those invocations instead, classifying each argv token from the workflow text.

Verification head sha: e98bfc87. Every gate below and every number in this body was run or read at that commit.

1. The measurement — every CI invocation of the nine, token by token

Read from .github/workflows/*.yml at origin/main (369da918). A literal appears verbatim in the workflow and is the same on every run; a variable is a GitHub expression, a shell expansion, a step output, or a path an earlier step produced.

ScriptWorkflow · job / stepInvocation as CI runs itValue tokensClass
check-adr-0087-registrationpr-automation.yml · "Require an ADR-0087 disposition…"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASE (env from steps.diffbase.outputs.merge_base)VARIABLE
cut-rc.yml · "Gate — this cut introduces no unregistered breaking change"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-changeset-no-majorpr-automation.yml · "…no major bump" (one-liner)--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no major bump"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-empty-changesetpr-automation.yml · "…no empty-frontmatter changeset"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no empty-frontmatter changeset"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-engine-split-ratiolint.yml · shallow-history guard collector--self-testnoneLITERAL
engine-split-metric.yml · "Compute ratio (90-day window)"--days 9090 written in the workflowLITERAL
check-test-completenessci.yml · Test Core "Test completeness guard""$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" (continued over 3 lines)$RUNNER_TEMPVARIABLE
ci.yml · Dogfood "Test completeness guard""$RUNNER_TEMP/dogfood.log"$RUNNER_TEMPVARIABLE
check-shard-attestationci.yml · test "Attest this shard ran and passed"--emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMP (test, 6 are literals)VARIABLE
ci.yml · dogfood "Attest this shard ran and passed"--emit --job dogfood --shard ${{ matrix.shard }} --total 3 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMPVARIABLE
ci.yml · dogfood-verify "Attest this leg ran and passed"--emit --job dogfood-verify --shard 1 --total 1 --out "$RUNNER_TEMP/shard-attestation"$RUNNER_TEMP only (1, 1 are literals)VARIABLE
ci.yml · test-gate "Verify test shard results"--verify --gate 'Test Core' --dir "$OS_ATTEST_DIR" --filter-result "$OS_FILTER_RESULT" --download-outcome "$OS_DOWNLOAD_OUTCOME" --leg "test/6:$OS_TEST_RESULT"4 env vars ('Test Core' is a literal)VARIABLE
ci.yml · dogfood-gate "Verify dogfood shard results"--verify --gate 'Dogfood Regression Gate' --dir … --leg "dogfood/3:$OS_DOGFOOD_RESULT" --leg "dogfood-verify/1:$OS_VERIFY_RESULT"5 env varsVARIABLE
check-required-contextsrequired-set-patrol.yml · "Run the live required-set sweep"--verify-required-set (continued, then two redirections)none — the continuation carries only redirectionsLITERAL
check-cross-package-test-inputsci.yml · filter job, affected-set union--union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt" (continued over 3 lines)$RUNNER_TEMPVARIABLE
check-prerelease-pin-watchprerelease-pin-watch.yml · "Self-test the probe"--self-testnoneLITERAL
prerelease-pin-watch.yml · "Probe npm for a stable release"--verbose then 2 redirected to 1none — the 2 was the redirection's own file descriptorLITERAL

Three of the nine are not value-bearing at all.check-engine-split-ratio --days 90 writes its value down; check-required-contexts --verify-required-set and check-prerelease-pin-watch --verbose have complete argv that were refused only because a continuation and a redirection file descriptor stood behind them. Those three now render as runnable commands.

The split reads off the workflow text for all fifteen invocations, so the triage's ⛔ holds: no per-script declaration table is owed.

Two members the card's table does not name

Re-measured at origin/main rather than taken from the card:

  • scripts/pm/check-half-states.mjs is a tenth member of the same class.half-state-patrol.yml runs it --format=markdown --provenance="$PROVENANCE" (continued, then redirected) and nowhere else, where PROVENANCE is a workflow env built from github.run_id / github.sha / github.event_name. It derived under a bare key too.
  • check-release-section-coverage.mjs --strict is a second invocation in release-coverage-patrol.yml that had no entry of its own — a continuation hid it, and the script's bare key (which lint.yml really does run) absorbed it. It is now its own runnable family, and the bare key stays where CI runs it bare.

What the bare keys actually did, run at origin/main

This is why the bare key is not a neutral omission:

Bare invocationexitfirst line
node scripts/check-test-completeness.mjs3PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.
node scripts/check-required-contexts.mjs0runs the static pin check — required-set-patrol.yml runs the live--verify-required-set sweep
node scripts/check-shard-attestation.mjs0duplicates what pnpm check:shard-attestation (a separate family) already runs; ci.yml's --emit/--verify runs are a different program
node scripts/check-cross-package-test-inputs.mjs0same shape — pnpm check:cross-package-test-inputs already covers it
the three --base gates0diff against their default base, while CI pins the merge base or the cut snapshot
node scripts/check-engine-split-ratio.mjs2shallow-clone refusal in this container; engine-split-metric.yml checks out with fetch-depth: 0

2. What changed in scripts/pm/dispatch-gates.mjs

  • joinLineContinuations splices a continued command into one line before any matcher runs, so the captured tail is the whole argv rather than the first physical line of it. ⛔ A comment line is never joined — a # comment runs to the newline, so its trailing backslash is comment text (measured: 107 continuation lines inside run: bodies on this tree, zero of them comments).
  • tailBeforeRedirection drops a redirection's file descriptor. DIRECT_CHECK_INVOCATION correctly stops the tail before the redirection operator, but 2 redirected to 1 puts its fd on the argv side of that boundary, so --verbose captured as --verbose 2. Discriminator is the shell's own: digits touching the operator are a descriptor, digits with a space before it are an argument.
  • argvTokens is a quote-aware tokenizer that also holds a ${{ … }} expression together — both spellings are live in ci.yml (--gate 'Test Core', --shard ${{ matrix.shard }}) and a bare whitespace split would classify their pieces separately.
  • renderedArgv replaces renderableArgv. It renders every tail and returns the workflow values it carries, so the caller decides what to do rather than the classifier refusing.
  • entry.notRunnable is the second not-runnable-here classification beside entry.ciOnly, and is deliberately kept separate from it: one family cannot run here because it needs the workflow event payload, the other because it needs a workflow value, and folding them would print one count for two omissions.
  • Renderings: a Value-bearing argv — matched by path, and NOT runnable here heading in the human output (below the blank line the published harvest stops at, so it cannot reach a harvest of the pasted block), a notRunnable field on the --json matched row, a stated omission on --commands stderr, and its own term in the reconciliation.

The usage line needed nothing.--commands promises "one runnable command per line on stdout, nothing else", and that promise is now more true, not less — the entries this adds are the ones it must not carry. PR #15105's --changed --commands line is untouched.

3. The nine entries, before and after

Read from discoverFamilies() at 369da918 and at e98bfc87.

Before (bare key CI never runs)After
scripts/check-adr-0087-registration.mjs… --base "$MERGE_BASE" [pr-automation.yml] NOT RUNNABLE · … --base "$SNAPSHOT_SHA" [cut-rc.yml] NOT RUNNABLE
scripts/check-changeset-no-major.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-empty-changeset.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-engine-split-ratio.mjsnode scripts/check-engine-split-ratio.mjs --days 90runnable, in --commands
scripts/check-test-completeness.mjs… "$RUNNER_TEMP/test-core.log" --scheduled … --package-list …NOT RUNNABLE · … "$RUNNER_TEMP/dogfood.log"NOT RUNNABLE
scripts/check-shard-attestation.mjsfive entries, one per ci.yml invocation, all NOT RUNNABLE (pnpm check:shard-attestation in lint.yml is a separate family and stays runnable)
scripts/check-required-contexts.mjsnode scripts/check-required-contexts.mjs --verify-required-setrunnable, in --commands
scripts/check-cross-package-test-inputs.mjs… --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"NOT RUNNABLE (pnpm check:cross-package-test-inputs stays runnable)
scripts/check-prerelease-pin-watch.mjsnode scripts/check-prerelease-pin-watch.mjs --verboserunnable, in --commands
scripts/pm/check-half-states.mjs (tenth member)… --format=markdown --provenance="$PROVENANCE"NOT RUNNABLE
scripts/check-release-section-coverage.mjs (bare; the --strict run had no entry)bare key kept (lint.yml runs it bare) plus… --strictrunnable, in --commands

The price, measured the way PR #15081 priced its own

Over the tracked tree, before to after:

check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every family present in both derivations keeps
its exact workflow set and its exact hint count

Verified as a set difference, not as a count: the only keys removed are the ten bare ones above, and every key added is one of the rendered invocations.

Live rendering, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack .github/workflows/ci.yml

Before, in the pasted block (four commands CI never runs):

 - node scripts/check-cross-package-test-inputs.mjs [ci.yml] matched via …
- node scripts/check-required-contexts.mjs [required-set-patrol.yml] matched via …
- node scripts/check-shard-attestation.mjs [ci.yml] matched via …
- node scripts/check-test-completeness.mjs [ci.yml] matched via …
Reconciliation — 40 famil(ies)

After — one of them promoted into the pasted block as the invocation CI really runs, the other eight under their own heading:

 - node scripts/check-required-contexts.mjs --verify-required-set [required-set-patrol.yml] matched via …
Value-bearing argv — matched by path, and NOT runnable here (8 famil(ies)):
- node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $RUNNER_TEMP
- node scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: ${{ matrix.shard }}, $RUNNER_TEMP
… 6 more
⇒ Printed as CI spells it, variable names in the value positions, so nothing above reads as a command to paste: these are ⛔ NOT in --commands, and a BARE run of any of these scripts is an invocation CI never makes.
⇒ Read from the workflow text, not from a table in this script: a token carrying ${{ … }} or a shell expansion is a variable, everything else is a literal and renders in full under the heading above.
Reconciliation — 37 famil(ies)
+ 8 famil(ies) this card's paths reach take a VALUE FROM THE WORKFLOW and sit OUTSIDE this total — …

--commands on those paths: 37 lines on stdout, zero containing a workflow variable, with the 8 stated on stderr. --json carries notRunnable on all 8 matched rows and none of their commands appear in commands.

4. Self-test — 1338 cases pass

Cases added (all PASS):

CaseWhat it pins
an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gonethe variable kind, quoted from pr-automation.yml
…and it carries the variable it takes from the workflow$MERGE_BASE reaches the row label
an invocation CONTINUED across two lines renders as ONE command, values and allthe ci.yml attestation, three physical lines
…with BOTH of its workflow variables named, the expression and the shell expansion${{ matrix.shard }} and $RUNNER_TEMP
…and ⛔ no bare key survives beside itthe bare key is gone, not merely joined
an invocation whose every token is a LITERAL renders as the command CI runs, value included--days 90, --verify-required-set, --verbose
…and every one of them is runnable — no variable, so nothing to labelthe literal kind stays in --commands
…and none of the three keeps a bare key CI never runsthe control in the other direction
⛔ no derived key is a TRUNCATED argvno continuation backslash, no redirection fd as an argument
renderedArgv renders every tail and reports which values come from the workflowthe classifier directly
argvTokens holds a quoted value and a ${{ … }} expression togetherthe tokenizer
joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENTthe join and its shell-correct refusal
the live tree really carries N value-bearing invocation(s) across M script(s)the class is live, count read not typed
the card's named specimens all classify as VARIABLE from the workflow textno per-script table was needed
…and the three the card called value-bearing that are really LITERAL render as runnable commands insteadthe card's own table corrected by measurement
⛔ no live key survives as a bare path for a script CI only ever invokes WITH argvthe defect, closed on the live tree
⛔ and no live key is truncatedacross all live workflows
a script CI invokes bare AND with argv keeps BOTH entriesthe third clause, with check-release-section-coverage as the live specimen
…each under the argv that file really runs it with, merge base and allthe keyed half of the live pr-automation cases

Cases rewritten — six, and each is named because "existing cases keep their verdicts" deserves an honest accounting. All six still PASS and each keeps its subject:

  • Two pinned the refusal this card retires (an invocation whose tail carries a VALUE keeps the bare path key, an invocation CONTINUED onto the next line keeps the bare key). Same fixture, same tail — the expectation moved from "keeps the bare key" to "renders in full and says whether it is runnable", because that bare key is the defect.
  • renderableArgv keeps a complete flag run and refuses everything else became renderedArgv renders every tail and reports which values come from the workflow — the function it pins was replaced.
  • every derived key is either the bare script path or that path plus a complete flag run became …plus the WHOLE argv, re-tokenising to itself — the same "never a truncated argv" property, restated for a renderer.
  • Three live cases (the live Check Changeset job discovers its ADR-0087 gate and its two siblings) asserted discovery by bare key; they now assert it by script, which is what their own comment says they are about, with a new case beside them pinning the keyed half so a rewrite of the step cannot satisfy them vacuously.
  • One pending-changeset case counted node scripts/check-empty-changeset.mjs exactly once; it now counts node scripts/check-empty-changeset.mjs --base "$MERGE_BASE", which is the family that section is about.

Cases untouched and still passing: everything else, including the two census cases PR #15081 added and the redirection case (a complete flag run followed by a redirection IS keyed).

5. scripts/pm/bare-root-worklist.mjs — the pre-authorised second file

node scripts/pm/bare-root-worklist.mjs --self-test reddened exactly as the dispatch anticipated, with 3 STALE and 6 FRESH rows, all for check-adr-0087-registration's re-keying. Its own printed remedy was followed and nothing else in the file was touched: the three rows keyed on the retired bare path are deleted, and six second-key twins recorded in their place (one per workflow invocation), verdict SPELLABLE-UNDECLARED and spelling carried whole from the retired row, with no count restated — this pass measured none, which is that file's own rule.

Green after: OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.

6. Gates — every one derived for this diff, at e98bfc87

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the commit. Exit code captured by redirecting first, never across a pipe; each row quotes the gate's own verdict line.

Gateexitverdict line
node scripts/check-ci-filter-parity.mjs0OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg…
node scripts/check-closing-keyword-parity.mjs0check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords…, all registered).
node scripts/check-closing-keyword-parity.mjs --self-test0✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs0✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 44.4s
node scripts/check-self-test-wired.mjs0✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test0check-self-test-wired --self-test: 4 live ledger row(s) verified… every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs0✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s)…
node scripts/check-whole-set-label-write.mjs --self-test0✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 69 live row(s)… none stale, none missing, none contradicted.
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 437 file(s)…
pnpm check:bash32-floor0✓ check-bash32-floor: 26 tracked shell file(s)… name no bash 4+ construct…
pnpm check:cli-command-ids0✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s)… all resolve
pnpm check:cross-package-test-inputs0OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live0✓ check:declared-population-live — 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8230 tracked file(s).
pnpm check:entry-guard0✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 8223 text file(s)… no raw ASCII control bytes).
pnpm check:parse-guard0✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1338 cases pass.
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 142/181 --filter occurrence(s) across 33 file(s) resolve…
pnpm check:refd-timer-probe0OK check-refd-timer-probe: 5848 source file(s) swept…
pnpm check:watch-hint-literal0✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s)… no unrostered spelling of the idiom in the tree.
pnpm lint (repo-wide eslint . --no-inline-config)0clean run, no output — run in full, not narrowed (60s under the shared verify lock)

The two long runs went through scripts/pm/os-verify-lock.sh: pm-dispatch-gatesVERDICT command-exit 0 · held the lock 250s, lintVERDICT command-exit 0 · held the lock 60s.

Two notes on scope of measurement, so nothing reads as verified that is not:

  • check-test-completeness is NOT MEASURED here, in its own words — it is not in this diff's derived gate list, and a bare run of it prints PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. (exit 3). That refusal is now the reason it leaves --commands, not a gate this PR skipped.
  • No vitest suite exercises either edited script.git grep for dispatch-gates / bare-root-worklist across *.test.* returns five files, and all five hits are prose comments citing dispatch-gates.mjs's own self-test as the thing that pins their property. That self-test is pnpm check:pm-dispatch-gates above.

7. The design call, on the four axes

The triage left one design choice open: how to render an invocation whose values are workflow variables. Options were (A) classify each token from the workflow text — literals render, variables render with the variable named and the entry marked not runnable; (B) a per-script declaration table of which flags are value-bearing; (C) keep the bare key.

  • Real business need. Measured, not supposed: fifteen live invocations across nine scripts, ten with a tenth found at head. One bare key returns a non-verdict (PREREQUISITE NOT MET, exit 3), four run a different program than CI runs, three answer against a different base. The consumer is real — the PM's dispatch brief and --ran both read this list. (C) keeps a measured defect; (B) buys nothing (A) does not, at the cost of a declaration surface.
  • Long-term soundness. (A) reads the workflow, which is the single source of what CI runs; it cannot drift, because the thing it reads is the thing it describes. (B) is a second source that must be kept in sync by hand — a de-facto second contract, which is what contract-first refuses. No workaround: the continuation and the redirection fd are fixed at the parse layer where they belong, not patched around at the classifier.
  • Making it structurally harder for an AI to get wrong. The rejected direction is the one that reads best: emitting the variable-bearing command into --commands with the variable unset. It runs, and it answers a question CI never asked — the tolerant-fallback shape, one layer up. (A) refuses loudly instead: the entry is named, its variables are named, and it is kept out of the list whose caption promises runnable. A missing lead, never a fabricated one.
  • Startup focus / no capability spread. (A) adds no flag, no output mode, no declaration surface and no configuration. It reuses the ciOnly channel's exact shape for a second measurement. (B) would have added a per-script table that grows with the workflow corpus forever.

Recommendation taken: (A). The triage's ⛔ on (B) is discharged by the measurement in section 1 rather than by assertion — every one of the fifteen invocations classifies from the workflow text alone.

Bounded scope

scripts/pm/dispatch-gates.mjs plus the pre-authorised verdict rows in scripts/pm/bare-root-worklist.mjs. No workflow edits, no .md, no other script. skip-changeset: nothing is published from these paths. Not a governed surface — draft PR for the seat's contract-tier review.


Generated by Claude Code

…back to a bare key
`dispatch-gates` keyed the argv half of `(script, args)` only when the tail was
a complete run of flag-shaped tokens. Every other invocation kept a BARE path
key — and for nine scripts that bare key is an invocation CI never makes.
Measured on this tree: `node scripts/check-test-completeness.mjs` exits 3 with
`PREREQUISITE NOT MET`; `check-required-contexts` runs the static pin check
while `required-set-patrol.yml` runs `--verify-required-set`; the three
`--base` gates answer against their default base while CI pins the merge base
or the cut snapshot.
Each argv token is now classified from the workflow text as a LITERAL (verbatim
in the workflow, same on every run) or a VARIABLE (`${{ … }}`, a shell
expansion, a step output). An all-literal invocation renders in full and enters
`--commands`; one carrying a variable renders with the variable's own name in
the value position, is marked NOT RUNNABLE LOCALLY on its own labelled line
under its own heading, and is kept out of `--commands`, out of the pasted block
and out of the published harvest — the same treatment `ciOnly` already gets,
reached by a different measurement. No per-script declaration table: the split
reads off the workflow for all fifteen live invocations.
Two mechanisms make the classification possible. `joinLineContinuations`
splices a continued command into one line before any matcher runs, so the tail
is the WHOLE argv rather than the first physical line of it (comments are never
joined — a `#` runs to the newline, so its backslash is comment text).
`tailBeforeRedirection` drops a redirection's file descriptor, which `2>&1` had
been leaving on the argv side of the boundary.
Measured, before -> after over the tracked tree:
check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every surviving family keeps its exact
workflow set and hint count
Beyond the nine the card named, the same sweep at this commit finds
`scripts/pm/check-half-states.mjs` (a tenth member: `half-state-patrol.yml`
runs it `--format=markdown --provenance="$PROVENANCE"` and nowhere else), and
`check-release-section-coverage.mjs --strict` — a second invocation in
`release-coverage-patrol.yml` that had no entry of its own because a
continuation hid it.
Self-test: 1338 cases pass. Two cases that pinned the retired refusal are
rewritten on the same fixtures to the new expectation; four that named a bare
key now name the script or the keyed invocation. New cases pin one literal
kind, one variable kind, the bare-invocation control, the tokenizer, the
continuation join and the live class read from the real workflows.
`scripts/pm/bare-root-worklist.mjs` records the verdict rows this re-keying
owes, per that script's own printed remedy: three rows for the retired bare
`check-adr-0087-registration` key are deleted and six second-key twins recorded
in their place, one per workflow invocation. Verdict and spelling carried
whole; no count restated, because this pass measured none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Side effect worth a triage read: the standing finding in #14899

That card measures node scripts/pm/check-half-states.mjs being derived into the per-PR gate family for any diff carrying a changeset — the scheduled live board sweep, which CI never runs on a PR, and which sat for 3m09s on a dev container blocking the rest of the derived list behind it.

This PR's classifier reaches it as the tenth member of the value-bearing class, so the behaviour that card measures changes. Measured on this branch at e98bfc87, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands .changeset/EXAMPLE.md:

pnpm check:pm-half-states [still there: the offline self-test lint.yml runs]
(no `node scripts/pm/check-half-states.mjs` line at all)

and in the human rendering it now appears once, under the new heading, as what half-state-patrol.yml really runs:

Value-bearing argv — matched by path, and NOT runnable here:
- node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" [half-state-patrol.yml] matched via .changeset/EXAMPLE.md ⇢ gate source '.changeset'
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $PROVENANCE

Two boundaries on that claim, stated so nothing here reads as more verified than it is:

  • The after state is measured on this branch. The before state is taken from that card's own quoted output plus this branch's baseline family reading at 369da918, where the family exists under the bare key scripts/pm/check-half-states.mjs with no not-runnable classification and therefore inside --commands. I did not re-run the base tool against that path.
  • ⛔ This PR makes no claim on that card and carries no closing keyword for it. It was filed against a defect that spans more than argv rendering (the routing question — a scheduled sweep reached through a .changeset watch hint — is untouched here), and its disposition is triage's, not this branch's.

Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 4, 2026 00:39
@os-steve
os-steve added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f594e70Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15083-value-bearing-argv branch September 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@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

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key - #15114

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv
Sep 4, 2026
Merged

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key#15114
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#15083

dispatch-gates keys the derivation on (script, args) and, since PR #15081, admitted the argv half only when the tail was a complete run of flag-shaped tokens. Nine scripts whose only CI invocations carry a value or a line continuation therefore kept a bare path key CI never runs bare. This renders those invocations instead, classifying each argv token from the workflow text.

Verification head sha: e98bfc87. Every gate below and every number in this body was run or read at that commit.

1. The measurement — every CI invocation of the nine, token by token

Read from .github/workflows/*.yml at origin/main (369da918). A literal appears verbatim in the workflow and is the same on every run; a variable is a GitHub expression, a shell expansion, a step output, or a path an earlier step produced.

ScriptWorkflow · job / stepInvocation as CI runs itValue tokensClass
check-adr-0087-registrationpr-automation.yml · "Require an ADR-0087 disposition…"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASE (env from steps.diffbase.outputs.merge_base)VARIABLE
cut-rc.yml · "Gate — this cut introduces no unregistered breaking change"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-changeset-no-majorpr-automation.yml · "…no major bump" (one-liner)--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no major bump"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-empty-changesetpr-automation.yml · "…no empty-frontmatter changeset"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no empty-frontmatter changeset"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-engine-split-ratiolint.yml · shallow-history guard collector--self-testnoneLITERAL
engine-split-metric.yml · "Compute ratio (90-day window)"--days 9090 written in the workflowLITERAL
check-test-completenessci.yml · Test Core "Test completeness guard""$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" (continued over 3 lines)$RUNNER_TEMPVARIABLE
ci.yml · Dogfood "Test completeness guard""$RUNNER_TEMP/dogfood.log"$RUNNER_TEMPVARIABLE
check-shard-attestationci.yml · test "Attest this shard ran and passed"--emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMP (test, 6 are literals)VARIABLE
ci.yml · dogfood "Attest this shard ran and passed"--emit --job dogfood --shard ${{ matrix.shard }} --total 3 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMPVARIABLE
ci.yml · dogfood-verify "Attest this leg ran and passed"--emit --job dogfood-verify --shard 1 --total 1 --out "$RUNNER_TEMP/shard-attestation"$RUNNER_TEMP only (1, 1 are literals)VARIABLE
ci.yml · test-gate "Verify test shard results"--verify --gate 'Test Core' --dir "$OS_ATTEST_DIR" --filter-result "$OS_FILTER_RESULT" --download-outcome "$OS_DOWNLOAD_OUTCOME" --leg "test/6:$OS_TEST_RESULT"4 env vars ('Test Core' is a literal)VARIABLE
ci.yml · dogfood-gate "Verify dogfood shard results"--verify --gate 'Dogfood Regression Gate' --dir … --leg "dogfood/3:$OS_DOGFOOD_RESULT" --leg "dogfood-verify/1:$OS_VERIFY_RESULT"5 env varsVARIABLE
check-required-contextsrequired-set-patrol.yml · "Run the live required-set sweep"--verify-required-set (continued, then two redirections)none — the continuation carries only redirectionsLITERAL
check-cross-package-test-inputsci.yml · filter job, affected-set union--union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt" (continued over 3 lines)$RUNNER_TEMPVARIABLE
check-prerelease-pin-watchprerelease-pin-watch.yml · "Self-test the probe"--self-testnoneLITERAL
prerelease-pin-watch.yml · "Probe npm for a stable release"--verbose then 2 redirected to 1none — the 2 was the redirection's own file descriptorLITERAL

Three of the nine are not value-bearing at all.check-engine-split-ratio --days 90 writes its value down; check-required-contexts --verify-required-set and check-prerelease-pin-watch --verbose have complete argv that were refused only because a continuation and a redirection file descriptor stood behind them. Those three now render as runnable commands.

The split reads off the workflow text for all fifteen invocations, so the triage's ⛔ holds: no per-script declaration table is owed.

Two members the card's table does not name

Re-measured at origin/main rather than taken from the card:

  • scripts/pm/check-half-states.mjs is a tenth member of the same class.half-state-patrol.yml runs it --format=markdown --provenance="$PROVENANCE" (continued, then redirected) and nowhere else, where PROVENANCE is a workflow env built from github.run_id / github.sha / github.event_name. It derived under a bare key too.
  • check-release-section-coverage.mjs --strict is a second invocation in release-coverage-patrol.yml that had no entry of its own — a continuation hid it, and the script's bare key (which lint.yml really does run) absorbed it. It is now its own runnable family, and the bare key stays where CI runs it bare.

What the bare keys actually did, run at origin/main

This is why the bare key is not a neutral omission:

Bare invocationexitfirst line
node scripts/check-test-completeness.mjs3PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.
node scripts/check-required-contexts.mjs0runs the static pin check — required-set-patrol.yml runs the live--verify-required-set sweep
node scripts/check-shard-attestation.mjs0duplicates what pnpm check:shard-attestation (a separate family) already runs; ci.yml's --emit/--verify runs are a different program
node scripts/check-cross-package-test-inputs.mjs0same shape — pnpm check:cross-package-test-inputs already covers it
the three --base gates0diff against their default base, while CI pins the merge base or the cut snapshot
node scripts/check-engine-split-ratio.mjs2shallow-clone refusal in this container; engine-split-metric.yml checks out with fetch-depth: 0

2. What changed in scripts/pm/dispatch-gates.mjs

  • joinLineContinuations splices a continued command into one line before any matcher runs, so the captured tail is the whole argv rather than the first physical line of it. ⛔ A comment line is never joined — a # comment runs to the newline, so its trailing backslash is comment text (measured: 107 continuation lines inside run: bodies on this tree, zero of them comments).
  • tailBeforeRedirection drops a redirection's file descriptor. DIRECT_CHECK_INVOCATION correctly stops the tail before the redirection operator, but 2 redirected to 1 puts its fd on the argv side of that boundary, so --verbose captured as --verbose 2. Discriminator is the shell's own: digits touching the operator are a descriptor, digits with a space before it are an argument.
  • argvTokens is a quote-aware tokenizer that also holds a ${{ … }} expression together — both spellings are live in ci.yml (--gate 'Test Core', --shard ${{ matrix.shard }}) and a bare whitespace split would classify their pieces separately.
  • renderedArgv replaces renderableArgv. It renders every tail and returns the workflow values it carries, so the caller decides what to do rather than the classifier refusing.
  • entry.notRunnable is the second not-runnable-here classification beside entry.ciOnly, and is deliberately kept separate from it: one family cannot run here because it needs the workflow event payload, the other because it needs a workflow value, and folding them would print one count for two omissions.
  • Renderings: a Value-bearing argv — matched by path, and NOT runnable here heading in the human output (below the blank line the published harvest stops at, so it cannot reach a harvest of the pasted block), a notRunnable field on the --json matched row, a stated omission on --commands stderr, and its own term in the reconciliation.

The usage line needed nothing.--commands promises "one runnable command per line on stdout, nothing else", and that promise is now more true, not less — the entries this adds are the ones it must not carry. PR #15105's --changed --commands line is untouched.

3. The nine entries, before and after

Read from discoverFamilies() at 369da918 and at e98bfc87.

Before (bare key CI never runs)After
scripts/check-adr-0087-registration.mjs… --base "$MERGE_BASE" [pr-automation.yml] NOT RUNNABLE · … --base "$SNAPSHOT_SHA" [cut-rc.yml] NOT RUNNABLE
scripts/check-changeset-no-major.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-empty-changeset.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-engine-split-ratio.mjsnode scripts/check-engine-split-ratio.mjs --days 90runnable, in --commands
scripts/check-test-completeness.mjs… "$RUNNER_TEMP/test-core.log" --scheduled … --package-list …NOT RUNNABLE · … "$RUNNER_TEMP/dogfood.log"NOT RUNNABLE
scripts/check-shard-attestation.mjsfive entries, one per ci.yml invocation, all NOT RUNNABLE (pnpm check:shard-attestation in lint.yml is a separate family and stays runnable)
scripts/check-required-contexts.mjsnode scripts/check-required-contexts.mjs --verify-required-setrunnable, in --commands
scripts/check-cross-package-test-inputs.mjs… --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"NOT RUNNABLE (pnpm check:cross-package-test-inputs stays runnable)
scripts/check-prerelease-pin-watch.mjsnode scripts/check-prerelease-pin-watch.mjs --verboserunnable, in --commands
scripts/pm/check-half-states.mjs (tenth member)… --format=markdown --provenance="$PROVENANCE"NOT RUNNABLE
scripts/check-release-section-coverage.mjs (bare; the --strict run had no entry)bare key kept (lint.yml runs it bare) plus… --strictrunnable, in --commands

The price, measured the way PR #15081 priced its own

Over the tracked tree, before to after:

check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every family present in both derivations keeps
its exact workflow set and its exact hint count

Verified as a set difference, not as a count: the only keys removed are the ten bare ones above, and every key added is one of the rendered invocations.

Live rendering, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack .github/workflows/ci.yml

Before, in the pasted block (four commands CI never runs):

 - node scripts/check-cross-package-test-inputs.mjs [ci.yml] matched via …
- node scripts/check-required-contexts.mjs [required-set-patrol.yml] matched via …
- node scripts/check-shard-attestation.mjs [ci.yml] matched via …
- node scripts/check-test-completeness.mjs [ci.yml] matched via …
Reconciliation — 40 famil(ies)

After — one of them promoted into the pasted block as the invocation CI really runs, the other eight under their own heading:

 - node scripts/check-required-contexts.mjs --verify-required-set [required-set-patrol.yml] matched via …
Value-bearing argv — matched by path, and NOT runnable here (8 famil(ies)):
- node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $RUNNER_TEMP
- node scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: ${{ matrix.shard }}, $RUNNER_TEMP
… 6 more
⇒ Printed as CI spells it, variable names in the value positions, so nothing above reads as a command to paste: these are ⛔ NOT in --commands, and a BARE run of any of these scripts is an invocation CI never makes.
⇒ Read from the workflow text, not from a table in this script: a token carrying ${{ … }} or a shell expansion is a variable, everything else is a literal and renders in full under the heading above.
Reconciliation — 37 famil(ies)
+ 8 famil(ies) this card's paths reach take a VALUE FROM THE WORKFLOW and sit OUTSIDE this total — …

--commands on those paths: 37 lines on stdout, zero containing a workflow variable, with the 8 stated on stderr. --json carries notRunnable on all 8 matched rows and none of their commands appear in commands.

4. Self-test — 1338 cases pass

Cases added (all PASS):

CaseWhat it pins
an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gonethe variable kind, quoted from pr-automation.yml
…and it carries the variable it takes from the workflow$MERGE_BASE reaches the row label
an invocation CONTINUED across two lines renders as ONE command, values and allthe ci.yml attestation, three physical lines
…with BOTH of its workflow variables named, the expression and the shell expansion${{ matrix.shard }} and $RUNNER_TEMP
…and ⛔ no bare key survives beside itthe bare key is gone, not merely joined
an invocation whose every token is a LITERAL renders as the command CI runs, value included--days 90, --verify-required-set, --verbose
…and every one of them is runnable — no variable, so nothing to labelthe literal kind stays in --commands
…and none of the three keeps a bare key CI never runsthe control in the other direction
⛔ no derived key is a TRUNCATED argvno continuation backslash, no redirection fd as an argument
renderedArgv renders every tail and reports which values come from the workflowthe classifier directly
argvTokens holds a quoted value and a ${{ … }} expression togetherthe tokenizer
joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENTthe join and its shell-correct refusal
the live tree really carries N value-bearing invocation(s) across M script(s)the class is live, count read not typed
the card's named specimens all classify as VARIABLE from the workflow textno per-script table was needed
…and the three the card called value-bearing that are really LITERAL render as runnable commands insteadthe card's own table corrected by measurement
⛔ no live key survives as a bare path for a script CI only ever invokes WITH argvthe defect, closed on the live tree
⛔ and no live key is truncatedacross all live workflows
a script CI invokes bare AND with argv keeps BOTH entriesthe third clause, with check-release-section-coverage as the live specimen
…each under the argv that file really runs it with, merge base and allthe keyed half of the live pr-automation cases

Cases rewritten — six, and each is named because "existing cases keep their verdicts" deserves an honest accounting. All six still PASS and each keeps its subject:

  • Two pinned the refusal this card retires (an invocation whose tail carries a VALUE keeps the bare path key, an invocation CONTINUED onto the next line keeps the bare key). Same fixture, same tail — the expectation moved from "keeps the bare key" to "renders in full and says whether it is runnable", because that bare key is the defect.
  • renderableArgv keeps a complete flag run and refuses everything else became renderedArgv renders every tail and reports which values come from the workflow — the function it pins was replaced.
  • every derived key is either the bare script path or that path plus a complete flag run became …plus the WHOLE argv, re-tokenising to itself — the same "never a truncated argv" property, restated for a renderer.
  • Three live cases (the live Check Changeset job discovers its ADR-0087 gate and its two siblings) asserted discovery by bare key; they now assert it by script, which is what their own comment says they are about, with a new case beside them pinning the keyed half so a rewrite of the step cannot satisfy them vacuously.
  • One pending-changeset case counted node scripts/check-empty-changeset.mjs exactly once; it now counts node scripts/check-empty-changeset.mjs --base "$MERGE_BASE", which is the family that section is about.

Cases untouched and still passing: everything else, including the two census cases PR #15081 added and the redirection case (a complete flag run followed by a redirection IS keyed).

5. scripts/pm/bare-root-worklist.mjs — the pre-authorised second file

node scripts/pm/bare-root-worklist.mjs --self-test reddened exactly as the dispatch anticipated, with 3 STALE and 6 FRESH rows, all for check-adr-0087-registration's re-keying. Its own printed remedy was followed and nothing else in the file was touched: the three rows keyed on the retired bare path are deleted, and six second-key twins recorded in their place (one per workflow invocation), verdict SPELLABLE-UNDECLARED and spelling carried whole from the retired row, with no count restated — this pass measured none, which is that file's own rule.

Green after: OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.

6. Gates — every one derived for this diff, at e98bfc87

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the commit. Exit code captured by redirecting first, never across a pipe; each row quotes the gate's own verdict line.

Gateexitverdict line
node scripts/check-ci-filter-parity.mjs0OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg…
node scripts/check-closing-keyword-parity.mjs0check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords…, all registered).
node scripts/check-closing-keyword-parity.mjs --self-test0✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs0✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 44.4s
node scripts/check-self-test-wired.mjs0✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test0check-self-test-wired --self-test: 4 live ledger row(s) verified… every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs0✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s)…
node scripts/check-whole-set-label-write.mjs --self-test0✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 69 live row(s)… none stale, none missing, none contradicted.
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 437 file(s)…
pnpm check:bash32-floor0✓ check-bash32-floor: 26 tracked shell file(s)… name no bash 4+ construct…
pnpm check:cli-command-ids0✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s)… all resolve
pnpm check:cross-package-test-inputs0OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live0✓ check:declared-population-live — 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8230 tracked file(s).
pnpm check:entry-guard0✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 8223 text file(s)… no raw ASCII control bytes).
pnpm check:parse-guard0✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1338 cases pass.
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 142/181 --filter occurrence(s) across 33 file(s) resolve…
pnpm check:refd-timer-probe0OK check-refd-timer-probe: 5848 source file(s) swept…
pnpm check:watch-hint-literal0✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s)… no unrostered spelling of the idiom in the tree.
pnpm lint (repo-wide eslint . --no-inline-config)0clean run, no output — run in full, not narrowed (60s under the shared verify lock)

The two long runs went through scripts/pm/os-verify-lock.sh: pm-dispatch-gatesVERDICT command-exit 0 · held the lock 250s, lintVERDICT command-exit 0 · held the lock 60s.

Two notes on scope of measurement, so nothing reads as verified that is not:

  • check-test-completeness is NOT MEASURED here, in its own words — it is not in this diff's derived gate list, and a bare run of it prints PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. (exit 3). That refusal is now the reason it leaves --commands, not a gate this PR skipped.
  • No vitest suite exercises either edited script.git grep for dispatch-gates / bare-root-worklist across *.test.* returns five files, and all five hits are prose comments citing dispatch-gates.mjs's own self-test as the thing that pins their property. That self-test is pnpm check:pm-dispatch-gates above.

7. The design call, on the four axes

The triage left one design choice open: how to render an invocation whose values are workflow variables. Options were (A) classify each token from the workflow text — literals render, variables render with the variable named and the entry marked not runnable; (B) a per-script declaration table of which flags are value-bearing; (C) keep the bare key.

  • Real business need. Measured, not supposed: fifteen live invocations across nine scripts, ten with a tenth found at head. One bare key returns a non-verdict (PREREQUISITE NOT MET, exit 3), four run a different program than CI runs, three answer against a different base. The consumer is real — the PM's dispatch brief and --ran both read this list. (C) keeps a measured defect; (B) buys nothing (A) does not, at the cost of a declaration surface.
  • Long-term soundness. (A) reads the workflow, which is the single source of what CI runs; it cannot drift, because the thing it reads is the thing it describes. (B) is a second source that must be kept in sync by hand — a de-facto second contract, which is what contract-first refuses. No workaround: the continuation and the redirection fd are fixed at the parse layer where they belong, not patched around at the classifier.
  • Making it structurally harder for an AI to get wrong. The rejected direction is the one that reads best: emitting the variable-bearing command into --commands with the variable unset. It runs, and it answers a question CI never asked — the tolerant-fallback shape, one layer up. (A) refuses loudly instead: the entry is named, its variables are named, and it is kept out of the list whose caption promises runnable. A missing lead, never a fabricated one.
  • Startup focus / no capability spread. (A) adds no flag, no output mode, no declaration surface and no configuration. It reuses the ciOnly channel's exact shape for a second measurement. (B) would have added a per-script table that grows with the workflow corpus forever.

Recommendation taken: (A). The triage's ⛔ on (B) is discharged by the measurement in section 1 rather than by assertion — every one of the fifteen invocations classifies from the workflow text alone.

Bounded scope

scripts/pm/dispatch-gates.mjs plus the pre-authorised verdict rows in scripts/pm/bare-root-worklist.mjs. No workflow edits, no .md, no other script. skip-changeset: nothing is published from these paths. Not a governed surface — draft PR for the seat's contract-tier review.


Generated by Claude Code

…back to a bare key
`dispatch-gates` keyed the argv half of `(script, args)` only when the tail was
a complete run of flag-shaped tokens. Every other invocation kept a BARE path
key — and for nine scripts that bare key is an invocation CI never makes.
Measured on this tree: `node scripts/check-test-completeness.mjs` exits 3 with
`PREREQUISITE NOT MET`; `check-required-contexts` runs the static pin check
while `required-set-patrol.yml` runs `--verify-required-set`; the three
`--base` gates answer against their default base while CI pins the merge base
or the cut snapshot.
Each argv token is now classified from the workflow text as a LITERAL (verbatim
in the workflow, same on every run) or a VARIABLE (`${{ … }}`, a shell
expansion, a step output). An all-literal invocation renders in full and enters
`--commands`; one carrying a variable renders with the variable's own name in
the value position, is marked NOT RUNNABLE LOCALLY on its own labelled line
under its own heading, and is kept out of `--commands`, out of the pasted block
and out of the published harvest — the same treatment `ciOnly` already gets,
reached by a different measurement. No per-script declaration table: the split
reads off the workflow for all fifteen live invocations.
Two mechanisms make the classification possible. `joinLineContinuations`
splices a continued command into one line before any matcher runs, so the tail
is the WHOLE argv rather than the first physical line of it (comments are never
joined — a `#` runs to the newline, so its backslash is comment text).
`tailBeforeRedirection` drops a redirection's file descriptor, which `2>&1` had
been leaving on the argv side of the boundary.
Measured, before -> after over the tracked tree:
check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every surviving family keeps its exact
workflow set and hint count
Beyond the nine the card named, the same sweep at this commit finds
`scripts/pm/check-half-states.mjs` (a tenth member: `half-state-patrol.yml`
runs it `--format=markdown --provenance="$PROVENANCE"` and nowhere else), and
`check-release-section-coverage.mjs --strict` — a second invocation in
`release-coverage-patrol.yml` that had no entry of its own because a
continuation hid it.
Self-test: 1338 cases pass. Two cases that pinned the retired refusal are
rewritten on the same fixtures to the new expectation; four that named a bare
key now name the script or the keyed invocation. New cases pin one literal
kind, one variable kind, the bare-invocation control, the tokenizer, the
continuation join and the live class read from the real workflows.
`scripts/pm/bare-root-worklist.mjs` records the verdict rows this re-keying
owes, per that script's own printed remedy: three rows for the retired bare
`check-adr-0087-registration` key are deleted and six second-key twins recorded
in their place, one per workflow invocation. Verdict and spelling carried
whole; no count restated, because this pass measured none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Side effect worth a triage read: the standing finding in #14899

That card measures node scripts/pm/check-half-states.mjs being derived into the per-PR gate family for any diff carrying a changeset — the scheduled live board sweep, which CI never runs on a PR, and which sat for 3m09s on a dev container blocking the rest of the derived list behind it.

This PR's classifier reaches it as the tenth member of the value-bearing class, so the behaviour that card measures changes. Measured on this branch at e98bfc87, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands .changeset/EXAMPLE.md:

pnpm check:pm-half-states [still there: the offline self-test lint.yml runs]
(no `node scripts/pm/check-half-states.mjs` line at all)

and in the human rendering it now appears once, under the new heading, as what half-state-patrol.yml really runs:

Value-bearing argv — matched by path, and NOT runnable here:
- node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" [half-state-patrol.yml] matched via .changeset/EXAMPLE.md ⇢ gate source '.changeset'
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $PROVENANCE

Two boundaries on that claim, stated so nothing here reads as more verified than it is:

  • The after state is measured on this branch. The before state is taken from that card's own quoted output plus this branch's baseline family reading at 369da918, where the family exists under the bare key scripts/pm/check-half-states.mjs with no not-runnable classification and therefore inside --commands. I did not re-run the base tool against that path.
  • ⛔ This PR makes no claim on that card and carries no closing keyword for it. It was filed against a defect that spans more than argv rendering (the routing question — a scheduled sweep reached through a .changeset watch hint — is untouched here), and its disposition is triage's, not this branch's.

Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 4, 2026 00:39
@os-steve
os-steve added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f594e70Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15083-value-bearing-argv branch September 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@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

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key - #15114

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv
Sep 4, 2026
Merged

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key#15114
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#15083

dispatch-gates keys the derivation on (script, args) and, since PR #15081, admitted the argv half only when the tail was a complete run of flag-shaped tokens. Nine scripts whose only CI invocations carry a value or a line continuation therefore kept a bare path key CI never runs bare. This renders those invocations instead, classifying each argv token from the workflow text.

Verification head sha: e98bfc87. Every gate below and every number in this body was run or read at that commit.

1. The measurement — every CI invocation of the nine, token by token

Read from .github/workflows/*.yml at origin/main (369da918). A literal appears verbatim in the workflow and is the same on every run; a variable is a GitHub expression, a shell expansion, a step output, or a path an earlier step produced.

ScriptWorkflow · job / stepInvocation as CI runs itValue tokensClass
check-adr-0087-registrationpr-automation.yml · "Require an ADR-0087 disposition…"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASE (env from steps.diffbase.outputs.merge_base)VARIABLE
cut-rc.yml · "Gate — this cut introduces no unregistered breaking change"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-changeset-no-majorpr-automation.yml · "…no major bump" (one-liner)--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no major bump"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-empty-changesetpr-automation.yml · "…no empty-frontmatter changeset"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no empty-frontmatter changeset"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-engine-split-ratiolint.yml · shallow-history guard collector--self-testnoneLITERAL
engine-split-metric.yml · "Compute ratio (90-day window)"--days 9090 written in the workflowLITERAL
check-test-completenessci.yml · Test Core "Test completeness guard""$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" (continued over 3 lines)$RUNNER_TEMPVARIABLE
ci.yml · Dogfood "Test completeness guard""$RUNNER_TEMP/dogfood.log"$RUNNER_TEMPVARIABLE
check-shard-attestationci.yml · test "Attest this shard ran and passed"--emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMP (test, 6 are literals)VARIABLE
ci.yml · dogfood "Attest this shard ran and passed"--emit --job dogfood --shard ${{ matrix.shard }} --total 3 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMPVARIABLE
ci.yml · dogfood-verify "Attest this leg ran and passed"--emit --job dogfood-verify --shard 1 --total 1 --out "$RUNNER_TEMP/shard-attestation"$RUNNER_TEMP only (1, 1 are literals)VARIABLE
ci.yml · test-gate "Verify test shard results"--verify --gate 'Test Core' --dir "$OS_ATTEST_DIR" --filter-result "$OS_FILTER_RESULT" --download-outcome "$OS_DOWNLOAD_OUTCOME" --leg "test/6:$OS_TEST_RESULT"4 env vars ('Test Core' is a literal)VARIABLE
ci.yml · dogfood-gate "Verify dogfood shard results"--verify --gate 'Dogfood Regression Gate' --dir … --leg "dogfood/3:$OS_DOGFOOD_RESULT" --leg "dogfood-verify/1:$OS_VERIFY_RESULT"5 env varsVARIABLE
check-required-contextsrequired-set-patrol.yml · "Run the live required-set sweep"--verify-required-set (continued, then two redirections)none — the continuation carries only redirectionsLITERAL
check-cross-package-test-inputsci.yml · filter job, affected-set union--union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt" (continued over 3 lines)$RUNNER_TEMPVARIABLE
check-prerelease-pin-watchprerelease-pin-watch.yml · "Self-test the probe"--self-testnoneLITERAL
prerelease-pin-watch.yml · "Probe npm for a stable release"--verbose then 2 redirected to 1none — the 2 was the redirection's own file descriptorLITERAL

Three of the nine are not value-bearing at all.check-engine-split-ratio --days 90 writes its value down; check-required-contexts --verify-required-set and check-prerelease-pin-watch --verbose have complete argv that were refused only because a continuation and a redirection file descriptor stood behind them. Those three now render as runnable commands.

The split reads off the workflow text for all fifteen invocations, so the triage's ⛔ holds: no per-script declaration table is owed.

Two members the card's table does not name

Re-measured at origin/main rather than taken from the card:

  • scripts/pm/check-half-states.mjs is a tenth member of the same class.half-state-patrol.yml runs it --format=markdown --provenance="$PROVENANCE" (continued, then redirected) and nowhere else, where PROVENANCE is a workflow env built from github.run_id / github.sha / github.event_name. It derived under a bare key too.
  • check-release-section-coverage.mjs --strict is a second invocation in release-coverage-patrol.yml that had no entry of its own — a continuation hid it, and the script's bare key (which lint.yml really does run) absorbed it. It is now its own runnable family, and the bare key stays where CI runs it bare.

What the bare keys actually did, run at origin/main

This is why the bare key is not a neutral omission:

Bare invocationexitfirst line
node scripts/check-test-completeness.mjs3PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.
node scripts/check-required-contexts.mjs0runs the static pin check — required-set-patrol.yml runs the live--verify-required-set sweep
node scripts/check-shard-attestation.mjs0duplicates what pnpm check:shard-attestation (a separate family) already runs; ci.yml's --emit/--verify runs are a different program
node scripts/check-cross-package-test-inputs.mjs0same shape — pnpm check:cross-package-test-inputs already covers it
the three --base gates0diff against their default base, while CI pins the merge base or the cut snapshot
node scripts/check-engine-split-ratio.mjs2shallow-clone refusal in this container; engine-split-metric.yml checks out with fetch-depth: 0

2. What changed in scripts/pm/dispatch-gates.mjs

  • joinLineContinuations splices a continued command into one line before any matcher runs, so the captured tail is the whole argv rather than the first physical line of it. ⛔ A comment line is never joined — a # comment runs to the newline, so its trailing backslash is comment text (measured: 107 continuation lines inside run: bodies on this tree, zero of them comments).
  • tailBeforeRedirection drops a redirection's file descriptor. DIRECT_CHECK_INVOCATION correctly stops the tail before the redirection operator, but 2 redirected to 1 puts its fd on the argv side of that boundary, so --verbose captured as --verbose 2. Discriminator is the shell's own: digits touching the operator are a descriptor, digits with a space before it are an argument.
  • argvTokens is a quote-aware tokenizer that also holds a ${{ … }} expression together — both spellings are live in ci.yml (--gate 'Test Core', --shard ${{ matrix.shard }}) and a bare whitespace split would classify their pieces separately.
  • renderedArgv replaces renderableArgv. It renders every tail and returns the workflow values it carries, so the caller decides what to do rather than the classifier refusing.
  • entry.notRunnable is the second not-runnable-here classification beside entry.ciOnly, and is deliberately kept separate from it: one family cannot run here because it needs the workflow event payload, the other because it needs a workflow value, and folding them would print one count for two omissions.
  • Renderings: a Value-bearing argv — matched by path, and NOT runnable here heading in the human output (below the blank line the published harvest stops at, so it cannot reach a harvest of the pasted block), a notRunnable field on the --json matched row, a stated omission on --commands stderr, and its own term in the reconciliation.

The usage line needed nothing.--commands promises "one runnable command per line on stdout, nothing else", and that promise is now more true, not less — the entries this adds are the ones it must not carry. PR #15105's --changed --commands line is untouched.

3. The nine entries, before and after

Read from discoverFamilies() at 369da918 and at e98bfc87.

Before (bare key CI never runs)After
scripts/check-adr-0087-registration.mjs… --base "$MERGE_BASE" [pr-automation.yml] NOT RUNNABLE · … --base "$SNAPSHOT_SHA" [cut-rc.yml] NOT RUNNABLE
scripts/check-changeset-no-major.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-empty-changeset.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-engine-split-ratio.mjsnode scripts/check-engine-split-ratio.mjs --days 90runnable, in --commands
scripts/check-test-completeness.mjs… "$RUNNER_TEMP/test-core.log" --scheduled … --package-list …NOT RUNNABLE · … "$RUNNER_TEMP/dogfood.log"NOT RUNNABLE
scripts/check-shard-attestation.mjsfive entries, one per ci.yml invocation, all NOT RUNNABLE (pnpm check:shard-attestation in lint.yml is a separate family and stays runnable)
scripts/check-required-contexts.mjsnode scripts/check-required-contexts.mjs --verify-required-setrunnable, in --commands
scripts/check-cross-package-test-inputs.mjs… --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"NOT RUNNABLE (pnpm check:cross-package-test-inputs stays runnable)
scripts/check-prerelease-pin-watch.mjsnode scripts/check-prerelease-pin-watch.mjs --verboserunnable, in --commands
scripts/pm/check-half-states.mjs (tenth member)… --format=markdown --provenance="$PROVENANCE"NOT RUNNABLE
scripts/check-release-section-coverage.mjs (bare; the --strict run had no entry)bare key kept (lint.yml runs it bare) plus… --strictrunnable, in --commands

The price, measured the way PR #15081 priced its own

Over the tracked tree, before to after:

check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every family present in both derivations keeps
its exact workflow set and its exact hint count

Verified as a set difference, not as a count: the only keys removed are the ten bare ones above, and every key added is one of the rendered invocations.

Live rendering, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack .github/workflows/ci.yml

Before, in the pasted block (four commands CI never runs):

 - node scripts/check-cross-package-test-inputs.mjs [ci.yml] matched via …
- node scripts/check-required-contexts.mjs [required-set-patrol.yml] matched via …
- node scripts/check-shard-attestation.mjs [ci.yml] matched via …
- node scripts/check-test-completeness.mjs [ci.yml] matched via …
Reconciliation — 40 famil(ies)

After — one of them promoted into the pasted block as the invocation CI really runs, the other eight under their own heading:

 - node scripts/check-required-contexts.mjs --verify-required-set [required-set-patrol.yml] matched via …
Value-bearing argv — matched by path, and NOT runnable here (8 famil(ies)):
- node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $RUNNER_TEMP
- node scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: ${{ matrix.shard }}, $RUNNER_TEMP
… 6 more
⇒ Printed as CI spells it, variable names in the value positions, so nothing above reads as a command to paste: these are ⛔ NOT in --commands, and a BARE run of any of these scripts is an invocation CI never makes.
⇒ Read from the workflow text, not from a table in this script: a token carrying ${{ … }} or a shell expansion is a variable, everything else is a literal and renders in full under the heading above.
Reconciliation — 37 famil(ies)
+ 8 famil(ies) this card's paths reach take a VALUE FROM THE WORKFLOW and sit OUTSIDE this total — …

--commands on those paths: 37 lines on stdout, zero containing a workflow variable, with the 8 stated on stderr. --json carries notRunnable on all 8 matched rows and none of their commands appear in commands.

4. Self-test — 1338 cases pass

Cases added (all PASS):

CaseWhat it pins
an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gonethe variable kind, quoted from pr-automation.yml
…and it carries the variable it takes from the workflow$MERGE_BASE reaches the row label
an invocation CONTINUED across two lines renders as ONE command, values and allthe ci.yml attestation, three physical lines
…with BOTH of its workflow variables named, the expression and the shell expansion${{ matrix.shard }} and $RUNNER_TEMP
…and ⛔ no bare key survives beside itthe bare key is gone, not merely joined
an invocation whose every token is a LITERAL renders as the command CI runs, value included--days 90, --verify-required-set, --verbose
…and every one of them is runnable — no variable, so nothing to labelthe literal kind stays in --commands
…and none of the three keeps a bare key CI never runsthe control in the other direction
⛔ no derived key is a TRUNCATED argvno continuation backslash, no redirection fd as an argument
renderedArgv renders every tail and reports which values come from the workflowthe classifier directly
argvTokens holds a quoted value and a ${{ … }} expression togetherthe tokenizer
joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENTthe join and its shell-correct refusal
the live tree really carries N value-bearing invocation(s) across M script(s)the class is live, count read not typed
the card's named specimens all classify as VARIABLE from the workflow textno per-script table was needed
…and the three the card called value-bearing that are really LITERAL render as runnable commands insteadthe card's own table corrected by measurement
⛔ no live key survives as a bare path for a script CI only ever invokes WITH argvthe defect, closed on the live tree
⛔ and no live key is truncatedacross all live workflows
a script CI invokes bare AND with argv keeps BOTH entriesthe third clause, with check-release-section-coverage as the live specimen
…each under the argv that file really runs it with, merge base and allthe keyed half of the live pr-automation cases

Cases rewritten — six, and each is named because "existing cases keep their verdicts" deserves an honest accounting. All six still PASS and each keeps its subject:

  • Two pinned the refusal this card retires (an invocation whose tail carries a VALUE keeps the bare path key, an invocation CONTINUED onto the next line keeps the bare key). Same fixture, same tail — the expectation moved from "keeps the bare key" to "renders in full and says whether it is runnable", because that bare key is the defect.
  • renderableArgv keeps a complete flag run and refuses everything else became renderedArgv renders every tail and reports which values come from the workflow — the function it pins was replaced.
  • every derived key is either the bare script path or that path plus a complete flag run became …plus the WHOLE argv, re-tokenising to itself — the same "never a truncated argv" property, restated for a renderer.
  • Three live cases (the live Check Changeset job discovers its ADR-0087 gate and its two siblings) asserted discovery by bare key; they now assert it by script, which is what their own comment says they are about, with a new case beside them pinning the keyed half so a rewrite of the step cannot satisfy them vacuously.
  • One pending-changeset case counted node scripts/check-empty-changeset.mjs exactly once; it now counts node scripts/check-empty-changeset.mjs --base "$MERGE_BASE", which is the family that section is about.

Cases untouched and still passing: everything else, including the two census cases PR #15081 added and the redirection case (a complete flag run followed by a redirection IS keyed).

5. scripts/pm/bare-root-worklist.mjs — the pre-authorised second file

node scripts/pm/bare-root-worklist.mjs --self-test reddened exactly as the dispatch anticipated, with 3 STALE and 6 FRESH rows, all for check-adr-0087-registration's re-keying. Its own printed remedy was followed and nothing else in the file was touched: the three rows keyed on the retired bare path are deleted, and six second-key twins recorded in their place (one per workflow invocation), verdict SPELLABLE-UNDECLARED and spelling carried whole from the retired row, with no count restated — this pass measured none, which is that file's own rule.

Green after: OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.

6. Gates — every one derived for this diff, at e98bfc87

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the commit. Exit code captured by redirecting first, never across a pipe; each row quotes the gate's own verdict line.

Gateexitverdict line
node scripts/check-ci-filter-parity.mjs0OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg…
node scripts/check-closing-keyword-parity.mjs0check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords…, all registered).
node scripts/check-closing-keyword-parity.mjs --self-test0✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs0✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 44.4s
node scripts/check-self-test-wired.mjs0✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test0check-self-test-wired --self-test: 4 live ledger row(s) verified… every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs0✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s)…
node scripts/check-whole-set-label-write.mjs --self-test0✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 69 live row(s)… none stale, none missing, none contradicted.
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 437 file(s)…
pnpm check:bash32-floor0✓ check-bash32-floor: 26 tracked shell file(s)… name no bash 4+ construct…
pnpm check:cli-command-ids0✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s)… all resolve
pnpm check:cross-package-test-inputs0OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live0✓ check:declared-population-live — 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8230 tracked file(s).
pnpm check:entry-guard0✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 8223 text file(s)… no raw ASCII control bytes).
pnpm check:parse-guard0✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1338 cases pass.
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 142/181 --filter occurrence(s) across 33 file(s) resolve…
pnpm check:refd-timer-probe0OK check-refd-timer-probe: 5848 source file(s) swept…
pnpm check:watch-hint-literal0✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s)… no unrostered spelling of the idiom in the tree.
pnpm lint (repo-wide eslint . --no-inline-config)0clean run, no output — run in full, not narrowed (60s under the shared verify lock)

The two long runs went through scripts/pm/os-verify-lock.sh: pm-dispatch-gatesVERDICT command-exit 0 · held the lock 250s, lintVERDICT command-exit 0 · held the lock 60s.

Two notes on scope of measurement, so nothing reads as verified that is not:

  • check-test-completeness is NOT MEASURED here, in its own words — it is not in this diff's derived gate list, and a bare run of it prints PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. (exit 3). That refusal is now the reason it leaves --commands, not a gate this PR skipped.
  • No vitest suite exercises either edited script.git grep for dispatch-gates / bare-root-worklist across *.test.* returns five files, and all five hits are prose comments citing dispatch-gates.mjs's own self-test as the thing that pins their property. That self-test is pnpm check:pm-dispatch-gates above.

7. The design call, on the four axes

The triage left one design choice open: how to render an invocation whose values are workflow variables. Options were (A) classify each token from the workflow text — literals render, variables render with the variable named and the entry marked not runnable; (B) a per-script declaration table of which flags are value-bearing; (C) keep the bare key.

  • Real business need. Measured, not supposed: fifteen live invocations across nine scripts, ten with a tenth found at head. One bare key returns a non-verdict (PREREQUISITE NOT MET, exit 3), four run a different program than CI runs, three answer against a different base. The consumer is real — the PM's dispatch brief and --ran both read this list. (C) keeps a measured defect; (B) buys nothing (A) does not, at the cost of a declaration surface.
  • Long-term soundness. (A) reads the workflow, which is the single source of what CI runs; it cannot drift, because the thing it reads is the thing it describes. (B) is a second source that must be kept in sync by hand — a de-facto second contract, which is what contract-first refuses. No workaround: the continuation and the redirection fd are fixed at the parse layer where they belong, not patched around at the classifier.
  • Making it structurally harder for an AI to get wrong. The rejected direction is the one that reads best: emitting the variable-bearing command into --commands with the variable unset. It runs, and it answers a question CI never asked — the tolerant-fallback shape, one layer up. (A) refuses loudly instead: the entry is named, its variables are named, and it is kept out of the list whose caption promises runnable. A missing lead, never a fabricated one.
  • Startup focus / no capability spread. (A) adds no flag, no output mode, no declaration surface and no configuration. It reuses the ciOnly channel's exact shape for a second measurement. (B) would have added a per-script table that grows with the workflow corpus forever.

Recommendation taken: (A). The triage's ⛔ on (B) is discharged by the measurement in section 1 rather than by assertion — every one of the fifteen invocations classifies from the workflow text alone.

Bounded scope

scripts/pm/dispatch-gates.mjs plus the pre-authorised verdict rows in scripts/pm/bare-root-worklist.mjs. No workflow edits, no .md, no other script. skip-changeset: nothing is published from these paths. Not a governed surface — draft PR for the seat's contract-tier review.


Generated by Claude Code

…back to a bare key
`dispatch-gates` keyed the argv half of `(script, args)` only when the tail was
a complete run of flag-shaped tokens. Every other invocation kept a BARE path
key — and for nine scripts that bare key is an invocation CI never makes.
Measured on this tree: `node scripts/check-test-completeness.mjs` exits 3 with
`PREREQUISITE NOT MET`; `check-required-contexts` runs the static pin check
while `required-set-patrol.yml` runs `--verify-required-set`; the three
`--base` gates answer against their default base while CI pins the merge base
or the cut snapshot.
Each argv token is now classified from the workflow text as a LITERAL (verbatim
in the workflow, same on every run) or a VARIABLE (`${{ … }}`, a shell
expansion, a step output). An all-literal invocation renders in full and enters
`--commands`; one carrying a variable renders with the variable's own name in
the value position, is marked NOT RUNNABLE LOCALLY on its own labelled line
under its own heading, and is kept out of `--commands`, out of the pasted block
and out of the published harvest — the same treatment `ciOnly` already gets,
reached by a different measurement. No per-script declaration table: the split
reads off the workflow for all fifteen live invocations.
Two mechanisms make the classification possible. `joinLineContinuations`
splices a continued command into one line before any matcher runs, so the tail
is the WHOLE argv rather than the first physical line of it (comments are never
joined — a `#` runs to the newline, so its backslash is comment text).
`tailBeforeRedirection` drops a redirection's file descriptor, which `2>&1` had
been leaving on the argv side of the boundary.
Measured, before -> after over the tracked tree:
check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every surviving family keeps its exact
workflow set and hint count
Beyond the nine the card named, the same sweep at this commit finds
`scripts/pm/check-half-states.mjs` (a tenth member: `half-state-patrol.yml`
runs it `--format=markdown --provenance="$PROVENANCE"` and nowhere else), and
`check-release-section-coverage.mjs --strict` — a second invocation in
`release-coverage-patrol.yml` that had no entry of its own because a
continuation hid it.
Self-test: 1338 cases pass. Two cases that pinned the retired refusal are
rewritten on the same fixtures to the new expectation; four that named a bare
key now name the script or the keyed invocation. New cases pin one literal
kind, one variable kind, the bare-invocation control, the tokenizer, the
continuation join and the live class read from the real workflows.
`scripts/pm/bare-root-worklist.mjs` records the verdict rows this re-keying
owes, per that script's own printed remedy: three rows for the retired bare
`check-adr-0087-registration` key are deleted and six second-key twins recorded
in their place, one per workflow invocation. Verdict and spelling carried
whole; no count restated, because this pass measured none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Side effect worth a triage read: the standing finding in #14899

That card measures node scripts/pm/check-half-states.mjs being derived into the per-PR gate family for any diff carrying a changeset — the scheduled live board sweep, which CI never runs on a PR, and which sat for 3m09s on a dev container blocking the rest of the derived list behind it.

This PR's classifier reaches it as the tenth member of the value-bearing class, so the behaviour that card measures changes. Measured on this branch at e98bfc87, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands .changeset/EXAMPLE.md:

pnpm check:pm-half-states [still there: the offline self-test lint.yml runs]
(no `node scripts/pm/check-half-states.mjs` line at all)

and in the human rendering it now appears once, under the new heading, as what half-state-patrol.yml really runs:

Value-bearing argv — matched by path, and NOT runnable here:
- node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" [half-state-patrol.yml] matched via .changeset/EXAMPLE.md ⇢ gate source '.changeset'
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $PROVENANCE

Two boundaries on that claim, stated so nothing here reads as more verified than it is:

  • The after state is measured on this branch. The before state is taken from that card's own quoted output plus this branch's baseline family reading at 369da918, where the family exists under the bare key scripts/pm/check-half-states.mjs with no not-runnable classification and therefore inside --commands. I did not re-run the base tool against that path.
  • ⛔ This PR makes no claim on that card and carries no closing keyword for it. It was filed against a defect that spans more than argv rendering (the routing question — a scheduled sweep reached through a .changeset watch hint — is untouched here), and its disposition is triage's, not this branch's.

Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 4, 2026 00:39
@os-steve
os-steve added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f594e70Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15083-value-bearing-argv branch September 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@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

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key - #15114

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv
Sep 4, 2026
Merged

tooling(pm): render a value-bearing CI invocation instead of falling back to a bare key#15114
os-steve merged 1 commit into
mainfrom
claude/issue-15083-value-bearing-argv

Conversation

@claude

@claudeclaudeBot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes#15083

dispatch-gates keys the derivation on (script, args) and, since PR #15081, admitted the argv half only when the tail was a complete run of flag-shaped tokens. Nine scripts whose only CI invocations carry a value or a line continuation therefore kept a bare path key CI never runs bare. This renders those invocations instead, classifying each argv token from the workflow text.

Verification head sha: e98bfc87. Every gate below and every number in this body was run or read at that commit.

1. The measurement — every CI invocation of the nine, token by token

Read from .github/workflows/*.yml at origin/main (369da918). A literal appears verbatim in the workflow and is the same on every run; a variable is a GitHub expression, a shell expansion, a step output, or a path an earlier step produced.

ScriptWorkflow · job / stepInvocation as CI runs itValue tokensClass
check-adr-0087-registrationpr-automation.yml · "Require an ADR-0087 disposition…"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASE (env from steps.diffbase.outputs.merge_base)VARIABLE
cut-rc.yml · "Gate — this cut introduces no unregistered breaking change"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-changeset-no-majorpr-automation.yml · "…no major bump" (one-liner)--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no major bump"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-empty-changesetpr-automation.yml · "…no empty-frontmatter changeset"--self-testnoneLITERAL
pr-automation.yml · same step--base "$MERGE_BASE"$MERGE_BASEVARIABLE
cut-rc.yml · "Gate — this cut introduces no empty-frontmatter changeset"--self-testnoneLITERAL
cut-rc.yml · same step--base "$SNAPSHOT_SHA"$SNAPSHOT_SHAVARIABLE
check-engine-split-ratiolint.yml · shallow-history guard collector--self-testnoneLITERAL
engine-split-metric.yml · "Compute ratio (90-day window)"--days 9090 written in the workflowLITERAL
check-test-completenessci.yml · Test Core "Test completeness guard""$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" (continued over 3 lines)$RUNNER_TEMPVARIABLE
ci.yml · Dogfood "Test completeness guard""$RUNNER_TEMP/dogfood.log"$RUNNER_TEMPVARIABLE
check-shard-attestationci.yml · test "Attest this shard ran and passed"--emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMP (test, 6 are literals)VARIABLE
ci.yml · dogfood "Attest this shard ran and passed"--emit --job dogfood --shard ${{ matrix.shard }} --total 3 --out "$RUNNER_TEMP/shard-attestation"${{ matrix.shard }}, $RUNNER_TEMPVARIABLE
ci.yml · dogfood-verify "Attest this leg ran and passed"--emit --job dogfood-verify --shard 1 --total 1 --out "$RUNNER_TEMP/shard-attestation"$RUNNER_TEMP only (1, 1 are literals)VARIABLE
ci.yml · test-gate "Verify test shard results"--verify --gate 'Test Core' --dir "$OS_ATTEST_DIR" --filter-result "$OS_FILTER_RESULT" --download-outcome "$OS_DOWNLOAD_OUTCOME" --leg "test/6:$OS_TEST_RESULT"4 env vars ('Test Core' is a literal)VARIABLE
ci.yml · dogfood-gate "Verify dogfood shard results"--verify --gate 'Dogfood Regression Gate' --dir … --leg "dogfood/3:$OS_DOGFOOD_RESULT" --leg "dogfood-verify/1:$OS_VERIFY_RESULT"5 env varsVARIABLE
check-required-contextsrequired-set-patrol.yml · "Run the live required-set sweep"--verify-required-set (continued, then two redirections)none — the continuation carries only redirectionsLITERAL
check-cross-package-test-inputsci.yml · filter job, affected-set union--union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt" (continued over 3 lines)$RUNNER_TEMPVARIABLE
check-prerelease-pin-watchprerelease-pin-watch.yml · "Self-test the probe"--self-testnoneLITERAL
prerelease-pin-watch.yml · "Probe npm for a stable release"--verbose then 2 redirected to 1none — the 2 was the redirection's own file descriptorLITERAL

Three of the nine are not value-bearing at all.check-engine-split-ratio --days 90 writes its value down; check-required-contexts --verify-required-set and check-prerelease-pin-watch --verbose have complete argv that were refused only because a continuation and a redirection file descriptor stood behind them. Those three now render as runnable commands.

The split reads off the workflow text for all fifteen invocations, so the triage's ⛔ holds: no per-script declaration table is owed.

Two members the card's table does not name

Re-measured at origin/main rather than taken from the card:

  • scripts/pm/check-half-states.mjs is a tenth member of the same class.half-state-patrol.yml runs it --format=markdown --provenance="$PROVENANCE" (continued, then redirected) and nowhere else, where PROVENANCE is a workflow env built from github.run_id / github.sha / github.event_name. It derived under a bare key too.
  • check-release-section-coverage.mjs --strict is a second invocation in release-coverage-patrol.yml that had no entry of its own — a continuation hid it, and the script's bare key (which lint.yml really does run) absorbed it. It is now its own runnable family, and the bare key stays where CI runs it bare.

What the bare keys actually did, run at origin/main

This is why the bare key is not a neutral omission:

Bare invocationexitfirst line
node scripts/check-test-completeness.mjs3PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named.
node scripts/check-required-contexts.mjs0runs the static pin check — required-set-patrol.yml runs the live--verify-required-set sweep
node scripts/check-shard-attestation.mjs0duplicates what pnpm check:shard-attestation (a separate family) already runs; ci.yml's --emit/--verify runs are a different program
node scripts/check-cross-package-test-inputs.mjs0same shape — pnpm check:cross-package-test-inputs already covers it
the three --base gates0diff against their default base, while CI pins the merge base or the cut snapshot
node scripts/check-engine-split-ratio.mjs2shallow-clone refusal in this container; engine-split-metric.yml checks out with fetch-depth: 0

2. What changed in scripts/pm/dispatch-gates.mjs

  • joinLineContinuations splices a continued command into one line before any matcher runs, so the captured tail is the whole argv rather than the first physical line of it. ⛔ A comment line is never joined — a # comment runs to the newline, so its trailing backslash is comment text (measured: 107 continuation lines inside run: bodies on this tree, zero of them comments).
  • tailBeforeRedirection drops a redirection's file descriptor. DIRECT_CHECK_INVOCATION correctly stops the tail before the redirection operator, but 2 redirected to 1 puts its fd on the argv side of that boundary, so --verbose captured as --verbose 2. Discriminator is the shell's own: digits touching the operator are a descriptor, digits with a space before it are an argument.
  • argvTokens is a quote-aware tokenizer that also holds a ${{ … }} expression together — both spellings are live in ci.yml (--gate 'Test Core', --shard ${{ matrix.shard }}) and a bare whitespace split would classify their pieces separately.
  • renderedArgv replaces renderableArgv. It renders every tail and returns the workflow values it carries, so the caller decides what to do rather than the classifier refusing.
  • entry.notRunnable is the second not-runnable-here classification beside entry.ciOnly, and is deliberately kept separate from it: one family cannot run here because it needs the workflow event payload, the other because it needs a workflow value, and folding them would print one count for two omissions.
  • Renderings: a Value-bearing argv — matched by path, and NOT runnable here heading in the human output (below the blank line the published harvest stops at, so it cannot reach a harvest of the pasted block), a notRunnable field on the --json matched row, a stated omission on --commands stderr, and its own term in the reconciliation.

The usage line needed nothing.--commands promises "one runnable command per line on stdout, nothing else", and that promise is now more true, not less — the entries this adds are the ones it must not carry. PR #15105's --changed --commands line is untouched.

3. The nine entries, before and after

Read from discoverFamilies() at 369da918 and at e98bfc87.

Before (bare key CI never runs)After
scripts/check-adr-0087-registration.mjs… --base "$MERGE_BASE" [pr-automation.yml] NOT RUNNABLE · … --base "$SNAPSHOT_SHA" [cut-rc.yml] NOT RUNNABLE
scripts/check-changeset-no-major.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-empty-changeset.mjs… --base "$MERGE_BASE"NOT RUNNABLE · … --base "$SNAPSHOT_SHA"NOT RUNNABLE
scripts/check-engine-split-ratio.mjsnode scripts/check-engine-split-ratio.mjs --days 90runnable, in --commands
scripts/check-test-completeness.mjs… "$RUNNER_TEMP/test-core.log" --scheduled … --package-list …NOT RUNNABLE · … "$RUNNER_TEMP/dogfood.log"NOT RUNNABLE
scripts/check-shard-attestation.mjsfive entries, one per ci.yml invocation, all NOT RUNNABLE (pnpm check:shard-attestation in lint.yml is a separate family and stays runnable)
scripts/check-required-contexts.mjsnode scripts/check-required-contexts.mjs --verify-required-setrunnable, in --commands
scripts/check-cross-package-test-inputs.mjs… --union-into "$RUNNER_TEMP/turbo-ls.json" --changed "$RUNNER_TEMP/changed-files.txt"NOT RUNNABLE (pnpm check:cross-package-test-inputs stays runnable)
scripts/check-prerelease-pin-watch.mjsnode scripts/check-prerelease-pin-watch.mjs --verboserunnable, in --commands
scripts/pm/check-half-states.mjs (tenth member)… --format=markdown --provenance="$PROVENANCE"NOT RUNNABLE
scripts/check-release-section-coverage.mjs (bare; the --strict run had no entry)bare key kept (lint.yml runs it bare) plus… --strictrunnable, in --commands

The price, measured the way PR #15081 priced its own

Over the tracked tree, before to after:

check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every family present in both derivations keeps
its exact workflow set and its exact hint count

Verified as a set difference, not as a count: the only keys removed are the ten bare ones above, and every key added is one of the rendered invocations.

Live rendering, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack .github/workflows/ci.yml

Before, in the pasted block (four commands CI never runs):

 - node scripts/check-cross-package-test-inputs.mjs [ci.yml] matched via …
- node scripts/check-required-contexts.mjs [required-set-patrol.yml] matched via …
- node scripts/check-shard-attestation.mjs [ci.yml] matched via …
- node scripts/check-test-completeness.mjs [ci.yml] matched via …
Reconciliation — 40 famil(ies)

After — one of them promoted into the pasted block as the invocation CI really runs, the other eight under their own heading:

 - node scripts/check-required-contexts.mjs --verify-required-set [required-set-patrol.yml] matched via …
Value-bearing argv — matched by path, and NOT runnable here (8 famil(ies)):
- node scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" --scheduled "$RUNNER_TEMP/shard-packages.txt" --package-list "$RUNNER_TEMP/turbo-ls.json" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $RUNNER_TEMP
- node scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} --total 6 --out "$RUNNER_TEMP/shard-attestation" [ci.yml] matched via …
⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: ${{ matrix.shard }}, $RUNNER_TEMP
… 6 more
⇒ Printed as CI spells it, variable names in the value positions, so nothing above reads as a command to paste: these are ⛔ NOT in --commands, and a BARE run of any of these scripts is an invocation CI never makes.
⇒ Read from the workflow text, not from a table in this script: a token carrying ${{ … }} or a shell expansion is a variable, everything else is a literal and renders in full under the heading above.
Reconciliation — 37 famil(ies)
+ 8 famil(ies) this card's paths reach take a VALUE FROM THE WORKFLOW and sit OUTSIDE this total — …

--commands on those paths: 37 lines on stdout, zero containing a workflow variable, with the 8 stated on stderr. --json carries notRunnable on all 8 matched rows and none of their commands appear in commands.

4. Self-test — 1338 cases pass

Cases added (all PASS):

CaseWhat it pins
an invocation whose tail carries a VARIABLE renders in FULL, and the bare key CI never runs is gonethe variable kind, quoted from pr-automation.yml
…and it carries the variable it takes from the workflow$MERGE_BASE reaches the row label
an invocation CONTINUED across two lines renders as ONE command, values and allthe ci.yml attestation, three physical lines
…with BOTH of its workflow variables named, the expression and the shell expansion${{ matrix.shard }} and $RUNNER_TEMP
…and ⛔ no bare key survives beside itthe bare key is gone, not merely joined
an invocation whose every token is a LITERAL renders as the command CI runs, value included--days 90, --verify-required-set, --verbose
…and every one of them is runnable — no variable, so nothing to labelthe literal kind stays in --commands
…and none of the three keeps a bare key CI never runsthe control in the other direction
⛔ no derived key is a TRUNCATED argvno continuation backslash, no redirection fd as an argument
renderedArgv renders every tail and reports which values come from the workflowthe classifier directly
argvTokens holds a quoted value and a ${{ … }} expression togetherthe tokenizer
joinLineContinuations splices a continued command into one line, and ⛔ never joins a COMMENTthe join and its shell-correct refusal
the live tree really carries N value-bearing invocation(s) across M script(s)the class is live, count read not typed
the card's named specimens all classify as VARIABLE from the workflow textno per-script table was needed
…and the three the card called value-bearing that are really LITERAL render as runnable commands insteadthe card's own table corrected by measurement
⛔ no live key survives as a bare path for a script CI only ever invokes WITH argvthe defect, closed on the live tree
⛔ and no live key is truncatedacross all live workflows
a script CI invokes bare AND with argv keeps BOTH entriesthe third clause, with check-release-section-coverage as the live specimen
…each under the argv that file really runs it with, merge base and allthe keyed half of the live pr-automation cases

Cases rewritten — six, and each is named because "existing cases keep their verdicts" deserves an honest accounting. All six still PASS and each keeps its subject:

  • Two pinned the refusal this card retires (an invocation whose tail carries a VALUE keeps the bare path key, an invocation CONTINUED onto the next line keeps the bare key). Same fixture, same tail — the expectation moved from "keeps the bare key" to "renders in full and says whether it is runnable", because that bare key is the defect.
  • renderableArgv keeps a complete flag run and refuses everything else became renderedArgv renders every tail and reports which values come from the workflow — the function it pins was replaced.
  • every derived key is either the bare script path or that path plus a complete flag run became …plus the WHOLE argv, re-tokenising to itself — the same "never a truncated argv" property, restated for a renderer.
  • Three live cases (the live Check Changeset job discovers its ADR-0087 gate and its two siblings) asserted discovery by bare key; they now assert it by script, which is what their own comment says they are about, with a new case beside them pinning the keyed half so a rewrite of the step cannot satisfy them vacuously.
  • One pending-changeset case counted node scripts/check-empty-changeset.mjs exactly once; it now counts node scripts/check-empty-changeset.mjs --base "$MERGE_BASE", which is the family that section is about.

Cases untouched and still passing: everything else, including the two census cases PR #15081 added and the redirection case (a complete flag run followed by a redirection IS keyed).

5. scripts/pm/bare-root-worklist.mjs — the pre-authorised second file

node scripts/pm/bare-root-worklist.mjs --self-test reddened exactly as the dispatch anticipated, with 3 STALE and 6 FRESH rows, all for check-adr-0087-registration's re-keying. Its own printed remedy was followed and nothing else in the file was touched: the three rows keyed on the retired bare path are deleted, and six second-key twins recorded in their place (one per workflow invocation), verdict SPELLABLE-UNDECLARED and spelling carried whole from the retired row, with no count restated — this pass measured none, which is that file's own rule.

Green after: OK self-test: 69 live row(s), 61 unreachable as spelled, 61 recorded verdict(s) — none stale, none missing, none contradicted.

6. Gates — every one derived for this diff, at e98bfc87

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) after the commit. Exit code captured by redirecting first, never across a pipe; each row quotes the gate's own verdict line.

Gateexitverdict line
node scripts/check-ci-filter-parity.mjs0OK: all 143 declared cross-package glob(s) (99 unique) are covered by core or crosspkg…
node scripts/check-closing-keyword-parity.mjs0check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords…, all registered).
node scripts/check-closing-keyword-parity.mjs --self-test0✓ check-closing-keyword-parity --self-test: 24 assertions, 5 mutations of the shipped parsers each driven to red.
node scripts/check-comment-mask-corpus.mjs0✓ comment-mask corpus sweep: 5853 files, 0 disagree, 0 unparseable, 44.4s
node scripts/check-self-test-wired.mjs0✓ check-self-test-wired: every one of the 166 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-self-test-wired.mjs --self-test0check-self-test-wired --self-test: 4 live ledger row(s) verified… every battery at or above its pinned floor.
node scripts/check-whole-set-label-write.mjs0✓ check-whole-set-label-write: 0 violations — 257 file(s) over 3 root(s)…
node scripts/check-whole-set-label-write.mjs --self-test0✓ check-whole-set-label-write --self-test: all cases pass (24 fixture trees + 5 refusals + 1 allowlist hatch)
node scripts/pm/bare-root-worklist.mjs --self-test0OK self-test: 69 live row(s)… none stale, none missing, none contradicted.
pnpm check:agent-test-spelling0✓ check-agent-test-spelling: 0 violations — 437 file(s)…
pnpm check:bash32-floor0✓ check-bash32-floor: 26 tracked shell file(s)… name no bash 4+ construct…
pnpm check:cli-command-ids0✓ check-cli-command-ids: 324 command-id literal(s) across 114 file(s)… all resolve
pnpm check:cross-package-test-inputs0OK: 26 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
pnpm check:declared-population-live0✓ check:declared-population-live — 198 of 249 famil(ies) declare a path population, and every one of them reaches this tree's 8230 tracked file(s).
pnpm check:entry-guard0✓ check:entry-guard: 206 scripts/ file(s) — every entry guard goes through invoked-as.mjs…
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 8223 text file(s)… no raw ASCII control bytes).
pnpm check:parse-guard0✓ check:parse-guard: 205 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
pnpm check:pm-dispatch-gates0✓ dispatch-gates self-test: 1338 cases pass.
pnpm check:pnpm-filter-targets0✓ check:pnpm-filter-targets: 142/181 --filter occurrence(s) across 33 file(s) resolve…
pnpm check:refd-timer-probe0OK check-refd-timer-probe: 5848 source file(s) swept…
pnpm check:watch-hint-literal0✓ check-watch-hint-literal: 48 declaration(s) across 4 rostered name(s)… no unrostered spelling of the idiom in the tree.
pnpm lint (repo-wide eslint . --no-inline-config)0clean run, no output — run in full, not narrowed (60s under the shared verify lock)

The two long runs went through scripts/pm/os-verify-lock.sh: pm-dispatch-gatesVERDICT command-exit 0 · held the lock 250s, lintVERDICT command-exit 0 · held the lock 60s.

Two notes on scope of measurement, so nothing reads as verified that is not:

  • check-test-completeness is NOT MEASURED here, in its own words — it is not in this diff's derived gate list, and a bare run of it prints PREREQUISITE NOT MET — this gate grades a saved turbo run test log, and no log was named. (exit 3). That refusal is now the reason it leaves --commands, not a gate this PR skipped.
  • No vitest suite exercises either edited script.git grep for dispatch-gates / bare-root-worklist across *.test.* returns five files, and all five hits are prose comments citing dispatch-gates.mjs's own self-test as the thing that pins their property. That self-test is pnpm check:pm-dispatch-gates above.

7. The design call, on the four axes

The triage left one design choice open: how to render an invocation whose values are workflow variables. Options were (A) classify each token from the workflow text — literals render, variables render with the variable named and the entry marked not runnable; (B) a per-script declaration table of which flags are value-bearing; (C) keep the bare key.

  • Real business need. Measured, not supposed: fifteen live invocations across nine scripts, ten with a tenth found at head. One bare key returns a non-verdict (PREREQUISITE NOT MET, exit 3), four run a different program than CI runs, three answer against a different base. The consumer is real — the PM's dispatch brief and --ran both read this list. (C) keeps a measured defect; (B) buys nothing (A) does not, at the cost of a declaration surface.
  • Long-term soundness. (A) reads the workflow, which is the single source of what CI runs; it cannot drift, because the thing it reads is the thing it describes. (B) is a second source that must be kept in sync by hand — a de-facto second contract, which is what contract-first refuses. No workaround: the continuation and the redirection fd are fixed at the parse layer where they belong, not patched around at the classifier.
  • Making it structurally harder for an AI to get wrong. The rejected direction is the one that reads best: emitting the variable-bearing command into --commands with the variable unset. It runs, and it answers a question CI never asked — the tolerant-fallback shape, one layer up. (A) refuses loudly instead: the entry is named, its variables are named, and it is kept out of the list whose caption promises runnable. A missing lead, never a fabricated one.
  • Startup focus / no capability spread. (A) adds no flag, no output mode, no declaration surface and no configuration. It reuses the ciOnly channel's exact shape for a second measurement. (B) would have added a per-script table that grows with the workflow corpus forever.

Recommendation taken: (A). The triage's ⛔ on (B) is discharged by the measurement in section 1 rather than by assertion — every one of the fifteen invocations classifies from the workflow text alone.

Bounded scope

scripts/pm/dispatch-gates.mjs plus the pre-authorised verdict rows in scripts/pm/bare-root-worklist.mjs. No workflow edits, no .md, no other script. skip-changeset: nothing is published from these paths. Not a governed surface — draft PR for the seat's contract-tier review.


Generated by Claude Code

…back to a bare key
`dispatch-gates` keyed the argv half of `(script, args)` only when the tail was
a complete run of flag-shaped tokens. Every other invocation kept a BARE path
key — and for nine scripts that bare key is an invocation CI never makes.
Measured on this tree: `node scripts/check-test-completeness.mjs` exits 3 with
`PREREQUISITE NOT MET`; `check-required-contexts` runs the static pin check
while `required-set-patrol.yml` runs `--verify-required-set`; the three
`--base` gates answer against their default base while CI pins the merge base
or the cut snapshot.
Each argv token is now classified from the workflow text as a LITERAL (verbatim
in the workflow, same on every run) or a VARIABLE (`${{ … }}`, a shell
expansion, a step output). An all-literal invocation renders in full and enters
`--commands`; one carrying a variable renders with the variable's own name in
the value position, is marked NOT RUNNABLE LOCALLY on its own labelled line
under its own heading, and is kept out of `--commands`, out of the pasted block
and out of the published harvest — the same treatment `ciOnly` already gets,
reached by a different measurement. No per-script declaration table: the split
reads off the workflow for all fifteen live invocations.
Two mechanisms make the classification possible. `joinLineContinuations`
splices a continued command into one line before any matcher runs, so the tail
is the WHOLE argv rather than the first physical line of it (comments are never
joined — a `#` runs to the newline, so its backslash is comment text).
`tailBeforeRedirection` drops a redirection's file descriptor, which `2>&1` had
been leaving on the argv side of the boundary.
Measured, before -> after over the tracked tree:
check families discovered 240 -> 249 (+9, ZERO lost)
watch-hint (gate, file) pairs 1499 -> 1528 (+29, ZERO lost)
re-attributed 0 — every surviving family keeps its exact
workflow set and hint count
Beyond the nine the card named, the same sweep at this commit finds
`scripts/pm/check-half-states.mjs` (a tenth member: `half-state-patrol.yml`
runs it `--format=markdown --provenance="$PROVENANCE"` and nowhere else), and
`check-release-section-coverage.mjs --strict` — a second invocation in
`release-coverage-patrol.yml` that had no entry of its own because a
continuation hid it.
Self-test: 1338 cases pass. Two cases that pinned the retired refusal are
rewritten on the same fixtures to the new expectation; four that named a bare
key now name the script or the keyed invocation. New cases pin one literal
kind, one variable kind, the bare-invocation control, the tokenizer, the
continuation join and the live class read from the real workflows.
`scripts/pm/bare-root-worklist.mjs` records the verdict rows this re-keying
owes, per that script's own printed remedy: three rows for the retired bare
`check-adr-0087-registration` key are deleted and six second-key twins recorded
in their place, one per workflow invocation. Verdict and spelling carried
whole; no count restated, because this pass measured none.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@claude

claudeBot commented Sep 4, 2026

Copy link
Copy Markdown
ContributorAuthor

Side effect worth a triage read: the standing finding in #14899

That card measures node scripts/pm/check-half-states.mjs being derived into the per-PR gate family for any diff carrying a changeset — the scheduled live board sweep, which CI never runs on a PR, and which sat for 3m09s on a dev container blocking the rest of the derived list behind it.

This PR's classifier reaches it as the tenth member of the value-bearing class, so the behaviour that card measures changes. Measured on this branch at e98bfc87, node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands .changeset/EXAMPLE.md:

pnpm check:pm-half-states [still there: the offline self-test lint.yml runs]
(no `node scripts/pm/check-half-states.mjs` line at all)

and in the human rendering it now appears once, under the new heading, as what half-state-patrol.yml really runs:

Value-bearing argv — matched by path, and NOT runnable here:
- node scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE" [half-state-patrol.yml] matched via .changeset/EXAMPLE.md ⇢ gate source '.changeset'
⛔ NOT RUNNABLE LOCALLY — 1 value(s) come from the workflow: $PROVENANCE

Two boundaries on that claim, stated so nothing here reads as more verified than it is:

  • The after state is measured on this branch. The before state is taken from that card's own quoted output plus this branch's baseline family reading at 369da918, where the family exists under the bare key scripts/pm/check-half-states.mjs with no not-runnable classification and therefore inside --commands. I did not re-run the base tool against that path.
  • ⛔ This PR makes no claim on that card and carries no closing keyword for it. It was filed against a defect that spans more than argv rendering (the routing question — a scheduled sweep reached through a .changeset watch hint — is untouched here), and its disposition is triage's, not this branch's.

Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 4, 2026 00:39
@os-steve
os-steve added this pull request to the merge queueSep 4, 2026
Merged via the queue into main with commit f594e70Sep 4, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15083-value-bearing-argv branch September 4, 2026 01:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants

@os-steve@claude