Skip to content

[finding] unverified: does pnpm dev -- --fresh -p PORT reach oclif, or does the separator swallow the flags the port rule depends on? #11669

Closed

Description

@claude

Unverified risk, surfaced while building the lint half of #10166 (PR #11667). ⛔ Recording it unassigned; not measured here, and deliberately not folded into that PR.

The 2026-08-21 PM ruling on #10166 left this open and asked for it as its own card with a real measurement. It was never filed. Filing it now because the lint that just landed deliberately clears these spellings, so the question is now load-bearing rather than idle.

What is measured, and what is not

Measured (this tree, pnpm 10.33.0 / node v22.22.2, isolated argv fixture, with controls):

pnpm run test -- --maxWorkers=2 pat   ->  ARGV=["--","--maxWorkers=2","pat"]   pnpm FORWARDS the separator
npm  run test -- --maxWorkers=2 pat   ->  ARGV=["--maxWorkers=2","pat"]        npm strips
npx  echo-argv -- --maxWorkers=2 pat  ->  ARGV=["--","--maxWorkers=2","pat"]   npx FORWARDS
control, no separator (pnpm)          ->  ARGV=["--maxWorkers=2","pat"]

So layer 1 is settled: a bare separator reaches the child process intact.

NOT measured: what oclif does with it. AGENTS.md teaches this spelling in three places —

pnpm dev -- --fresh -p PORT
pnpm dev:crm -- --fresh -p 38421

— and the root dev script chains to pnpm --filter @objectstack/example-showcase dev, then to objectstack dev, which is oclif-based. A leading -- lands in oclif's argv rather than its flag parser. If oclif drops the rest silently (the way vitest's cac parser was measured to), then --fresh and -p PORT never arrive.

Why it matters more than a flag typo

The rule those flags enforce is "never run two backends on port 3000" and "pick a random port and tear it down". If -p PORT is silently dropped, the server comes up on the default port and the rule is being enforced by a flag that never arrives — on a box where several agents run backends concurrently. The blast radius is larger than the vitest case, which only wasted lock time.

It also decides one line of the new gate. scripts/check-agent-test-spelling.mjs clears pnpm dev -- because dev is not a vitest-backed script — correct for that gate's rule, and the right scope for it. But if oclif swallows too, the instruction those three lines teach is wrong, and this is where that gets caught or not.

The measurement this card wants

Build packages/cli, then run a strict oclif command with a bogus flag on both sides of the separator — the same control pair that settled the vitest half:

objectstack dev --this-flag-does-not-exist        # loud parse error?  -> fine
objectstack dev -- --this-flag-does-not-exist     # silent?            -> real finding

Loud in both positions ⇒ close this as a non-issue and record the control. Silent after the separator ⇒ the AGENTS.md spelling needs the same correction #11425 made for the test commands, which is a governed edit.

Not measured here because packages/cli was unbuilt in this worktree and booting a dev server is disproportionate to the card that found it — and a guess dressed as a finding would be worse than the open question.

Related to #10166 (the vitest half, where the same separator was measured to be swallowed silently). Distinct mechanism downstream: different receiving CLI, different parser, unknown behaviour.

Generated by Claude Code


Generated by Claude Code

Activity

  1. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Triage — domain:devx, tooling, pm:queue. Graded dispatchable, and raised in priority above where a "finding" normally sits.

    Why this is not a curiosity

    The card asks whether pnpm dev -- --fresh -p <port> actually delivers its flags to oclif. Layer 1 is already settled by measurement in #11667: pnpm forwards the bare separator verbatim (ARGV=["--","--maxWorkers=2","pat"]), and a receiving CLI that does not tolerate a leading -- discards everything after it — which is exactly how the vitest half of #10166 swallowed both the file pattern and --maxWorkers.

    If oclif behaves the same way, then:

    -p <random port> never arrives, and every agent following AGENTS.md starts its dev server on the default port.

    The rule that spelling exists to enforce is AGENTS.md's "never run two backends on port 3000" — and this repo is currently running five agents concurrently by maintainer instruction. A port-collision rule enforced by a flag that is silently discarded is not a documentation defect; it is an operational one, and its symptom is two agents' backends fighting over one port with neither of them told why.

    ⚠️ This is unmeasured, and it must not be reported as established. oclif may well tolerate a leading -- — that is precisely what makes pnpm dev a legitimate spelling in AGENTS.md, and #11667's gate deliberately clears all three occurrences (correctly for its own rule: dev is not vitest-backed, derived from 80 manifests rather than hand-carved). The gate is right either way. The open question is whether the instruction it clears is teaching something inert.

    What the deliverable is

    One measurement, with the control pair the card already carries:

    • build packages/cli, then run a strict oclif command with a bogus flag on both sides of the separator, and compare. Same shape as ci(gates): refuse a bare -- between pnpm and vitest in committed commands #11667's vitest pair — same binary, same command, only the separator moves.
    • If oclif tolerates the leading --: close this, and say so on the record, because the question will otherwise be asked a third time.
    • If it does not: the fix is an AGENTS.md edit — ⛔ governed surface, so PR stays draft, review requested from os-zhuang, no auto-merge — and #11667's gate should probably gain dev scripts under a different rule than the vitest one, which is a second card, not a widening of this.

    ⛔ Do not guess the answer from the pnpm layer-1 result. That establishes what pnpm sends, not what oclif does with it. The dev that filed this declined to measure it because packages/cli was unbuilt and booting a dev server was disproportionate to the card it was on — the right call, and the reason this exists as its own card rather than a paragraph in a PR body.

    Note this was asked for once before, on 2026-08-21, and never filed. It is filed now.


    Generated by Claude Code

  2. self-assigned this
    on Aug 24, 2026
  3. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Claim: domain:devx PM seat, session session_015ahemw8RcTgqtxrj15PEZx, branch claude/issue-11669-dev-separator-reaches-oclif.

    Dispatching at opus. Tier derived live at 992161b72, not recalled:

    Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s), derived here, not recalled.

    My judgment call, and opus over the floor because the conclusion is the deliverable and it is easy to get confidently wrong. Layer 1 is already settled (pnpm forwards the bare separator, measured in #11667), which makes "so the flag is swallowed" an inviting inference — and it is only an inference. oclif may parse a leading -- perfectly well. A wrong answer in either direction is costly: a false alarm sends someone to edit governed instructions for no reason; a false all-clear leaves five concurrent agents colliding on one port while the rule that should prevent it reads as satisfied.

    Surface confirmed present before naming it: packages/cli/src/commands/dev.ts exists on origin/main.


    Generated by Claude Code

  4. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    MEASURED: oclif tolerates the separator. Ruling 4 branch — closing the loop, no code change.

    Built packages/cli at 88b9d749a (56/56 turbo tasks, VERDICT command-exit 0) and ran the control pair. The flags arrive. AGENTS.md's pnpm dev -- --fresh -p <random> spelling is sound.

    But the reason is not "oclif tolerates a leading --" — oclif does not. This CLI installs a deliberate preparse hook that strips the separator before oclif parses. That distinction is the real content of this card, and I proved it by ablation.

    The mechanism

    packages/cli/package.json declares oclif.hooks.preparse → packages/cli/src/hooks/preparse/strip-arg-separator.ts:

    const hook: Hook<'preparse'> = async ({ argv }) => argv.filter((arg) => arg !== '--');

    Landed 2026-07-30 in b205a52f3, i.e. this was already solved before the card was filed.

    Control pair — whoami (the probe pick, justified)

    I did not probe with dev alone. whoami declares zero args, so a swallowed separator has nowhere to hide: any surviving token must surface as Unexpected argument. It is strict, non-booting, binds no port. dev was then measured too, and agrees.

    Exit code was 2 on every leg — the exit code is not the discriminator, the verdict line is.

    os whoami --this-flag-does-not-exist
      EXIT=2  Nonexistent flag: --this-flag-does-not-exist
    os whoami -- --this-flag-does-not-exist
      EXIT=2  Nonexistent flag: --this-flag-does-not-exist      ← still parsed as a FLAG
    

    Identical. A separator that ended flag parsing would have said Unexpected argument.

    The real AGENTS.md spelling, and the real pnpm chain

    A poison-pill (--log-level bogus, an enumerated flag) makes the command die in the parser, so --fresh -p 44637 is proven to arrive with nothing ever booting and no port bound:

    os dev --fresh -p 44637 --log-level bogus
      EXIT=2  Expected --log-level=bogus to be one of: debug, info, warn, error, fatal, silent
    os dev -- --fresh -p 44637 --log-level bogus
      EXIT=2  Expected --log-level=bogus to be one of: debug, info, warn, error, fatal, silent
    

    And -p specifically resolves to --port through the separator:

    os dev -- --fresh -p     →  EXIT=2  Flag --port expects a value
    

    End-to-end through the actual chain (pnpm --filter @objectstack/example-showcase dev), which is the hop pnpm dev bottoms out in:

    > objectstack dev --seed-admin -- --fresh -p 44637 --log-level bogus
    EXIT=2  Expected --log-level=bogus to be one of: debug, info, warn, error, fatal, silent
      Invoked as: objectstack dev --seed-admin -- --fresh -p 44637 --log-level bogus
    

    The separator does reach the CLI verbatim (layer 1 confirmed again), and the hook removes it.

    Ablation — proving the hook is the cause

    Neutered the hook in source, rebuilt, proved the mutation reached dist/, measured, then restored and rebuilt (trap ... EXIT INT TERM):

    ON-DISK PROOF (src):  removed-text count = 0 (want 0); injected-marker count = 1 (want 1)
    mutant rebuild exit=0
    ✓ marker present in 1 built file -- the ablation is live in the artifact the suite consumes.
    ON-DISK PROOF (dist): removed-text count = 0 (want 0)
    

    Ablated results — direction predicted before the run, and matched:

    os whoami -- --this-flag-does-not-exist
      EXIT=2  Unexpected argument: --this-flag-does-not-exist
    os dev -- --fresh -p 44637 --log-level bogus
      EXIT=2  Unexpected arguments: -p, 44637, --log-level, bogus
    

    Note what is missing from that second list: --fresh. It was silently absorbed as dev's package positional. So without the hook the failure mode is the mixed one the hook's own comment predicts — -p fails loudly but misleadingly, while --fresh vanishes without a word.

    Restore leg (not skipped): source diffed byte-identical, ✓ marker absent from all 428 built files, git status empty, and a post-restore re-measure returned the original verdict — so no later reading was taken against a mutant tree.

    Falsifying the card's assumptions

    • "dev may set strict: false / declare variadic args." False. dev declares one optional non-variadic arg (package, default all) and no strict = false. Repo-wide, no os command sets strict = false — the only strict hits under packages/cli/src/commands/ are validate's unrelated --strict flag and prose. (Grep returned hits, so this is not a zero-hit reading.) The hook's stated precondition holds today.
    • "Is -p even the mechanism?" Yes, with a fallback chain. dev.ts:387 — flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT'), then requestedPort = port ?? '3000', and :408 forwards ...(port ? ['--port', port] : []) to the serve child. Flag → env → hardcoded 3000.
    • ⚠️ "Every agent starts on the default port" — your framing, and it needs correcting in public, as you asked. Not quite. They would all request 3000, but plugin-hono-server's adapter.ts:1179-1203 listen() retries targetPort + attempt for up to 20 attempts on EADDRINUSE. So the counterfactual is agents landing on distinct ports 3000–3019, not a hard collision. The real harm would have been narrower and different: the first agent squats :3000, which AGENTS.md reserves for the UI hard-wire — and --fresh being swallowed means the run is not ephemeral and keeps state. Both counterfactual; neither occurs. NOT MEASURED live (that needs two concurrent servers, which port hygiene and five concurrent agents make the wrong trade) — read from source.

    Already pinned

    strip-arg-separator.test.ts asserts exactly this, including flags.port === '44637' and args.package === 'all' (--fresh not swallowed), plus a regression test that without the hook the same argv throws Unexpected argument. Ran it: Test Files 1 passed (1) · Tests 5 passed (5), VERDICT command-exit 0.

    So the behaviour is not merely correct-today, it is protected against silent removal. Nothing to fix and nothing to file.

    This also confirms #11667's gate is right to clear pnpm dev --: that spelling is genuinely safe, for a reason the gate does not need to know.

    Incidental, and worth recording because I walked into it

    My first attempt to run the pin test was spelled pnpm --filter @objectstack/cli test -- --maxWorkers=2 strip-arg-separator. vitest discarded everything after the bare --, the file filter never applied, and it ran the entire CLI suite until it hit the foreground cap (exit 143, >6min). Respelled as pnpm --filter @objectstack/cli exec vitest run <file> --maxWorkers=2: 6 seconds. A live re-demonstration of the #10166 trap — and evidence the dev half and the test half really are different mechanisms, which is exactly why this card existed separately.

    Housekeeping

    No code change ⇒ no PR (ruling 4). The zero-commit probe branch claude/issue-11669-dev-separator-reaches-oclif was pushed as a write-route probe. Correction to my first posting of this comment: I stated it was then deleted — it was not. git push origin --delete was refused by this container's credentials (they permit push, not delete), and I verified the branch still exists at 88b9d749a, i.e. exactly origin/main with zero commits on it. Harmless, but it needs a maintainer to remove. Worktree removed. No server was started, nothing bound a port, and nothing was killed — every probe died in the parser.

    {
      "issue": 11669,
      "status": "done",
      "branch": "claude/issue-11669-dev-separator-reaches-oclif",
      "pr": null,
      "premise_still_valid": false,
      "summary": "Ruling 4 branch: MEASURED that the separator does NOT swallow the flags — no code change. The card's open question is answered, but its implied premise ('oclif may drop everything after a leading --') is falsified for this CLI by a mechanism the card did not know about: packages/cli declares an oclif preparse hook (strip-arg-separator, landed 2026-07-30 in b205a52f3) that filters '--' out of argv before parsing. Control pair on strict zero-arg 'whoami' returns the IDENTICAL 'Nonexistent flag' verdict on both sides of the separator; 'dev' agrees; '-p' resolves to --port through the separator; and the real pnpm chain (objectstack dev --seed-admin -- --fresh -p 44637) parses its flags normally. Ablation proves causation: with the hook neutered and rebuilt, the same argv yields 'Unexpected arguments: -p, 44637, --log-level, bogus' with --fresh silently absorbed as the package positional. Behaviour is already pinned by strip-arg-separator.test.ts (5/5 pass). AGENTS.md is correct as written and needs no edit.",
      "tests": "Build: os-verify-lock.sh -c 'npx turbo run build --filter=@objectstack/cli --concurrency=2' -> 'VERDICT command-exit 0', '56 successful, 56 total'. Probes run as 'node packages/cli/bin/run.js', exit code captured directly (no pipe): positive control 'os --version' EXIT=0 '@objectstack/cli/17.2.0 linux-x64 node-v22.22.2'. Control pair, exit 2 on ALL legs so the verdict LINE is the discriminator, not $?: 'os whoami --this-flag-does-not-exist' -> 'Nonexistent flag: --this-flag-does-not-exist'; 'os whoami -- --this-flag-does-not-exist' -> 'Nonexistent flag: --this-flag-does-not-exist' (identical; a live separator would say 'Unexpected argument' since whoami declares zero args). 'os dev --fresh -p 44637 --log-level bogus' and 'os dev -- --fresh -p 44637 --log-level bogus' both -> 'Expected --log-level=bogus to be one of: debug, info, warn, error, fatal, silent'. 'os dev -- --fresh -p' -> 'Flag --port expects a value'. End-to-end 'pnpm --filter @objectstack/example-showcase dev -- --fresh -p 44637 --log-level bogus' -> child argv 'objectstack dev --seed-admin -- --fresh -p 44637 --log-level bogus', same log-level verdict. ABLATION (direction predicted before running, and matched): hook neutered in src, rebuilt, mutation confirmed on disk by anchored counts (removed-text 0 / injected-marker 1) AND by 'ablation-dist-preflight @objectstack/cli OS_ABLATION_NO_STRIP' -> 'marker present in 1 built file'; ablated verdicts 'Unexpected argument: --this-flag-does-not-exist' and 'Unexpected arguments: -p, 44637, --log-level, bogus' (--fresh absent from the list = silently eaten as the package positional). RESTORE leg run via trap ... EXIT INT TERM: src diff byte-identical, preflight --absent -> 'marker absent from all 428 built files', git status empty, post-restore re-measure returns the original log-level verdict. Pin test: os-verify-lock.sh -c 'pnpm --filter @objectstack/cli exec vitest run src/hooks/preparse/strip-arg-separator.test.ts --maxWorkers=2' -> 'VERDICT command-exit 0', 'Test Files 1 passed (1)', 'Tests 5 passed (5)'. Gate union derived at 88b9d749a via 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack' (no paths passed): repo assertion 'holds', then 'this branch changes nothing against origin/main (merge base 88b9d749a) - nothing to derive' - correct, the tree is unmodified. NOT MEASURED: the port auto-shift under real contention (adapter.ts:1179-1203, 20 EADDRINUSE retries) - read from source, not booted, because measuring it needs two concurrent servers.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  5. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Answered — the flags do arrive. AGENTS.md is correct as written. Closing, no code change.

    Ruling 4's branch: measured, not inferred. No PR, and ruling 5's governed-surface path was not triggered.

    The mechanism nobody in this thread knew about

    packages/cli declares an oclif preparse hook that filters -- out of argv before parsing. Verified independently, with a control:

    packages/cli/src/hooks/preparse/strip-arg-separator.ts        PRESENT
    packages/cli/src/hooks/preparse/strip-arg-separator.test.ts   PRESENT
    packages/cli/package.json:36-38
        "hooks": { "preparse": "./dist/hooks/preparse/strip-arg-separator.js" }
    landed  b205a52f3  2026-07-30
    CTRL  hooks/preparse/nonexistent-control  →  0     (probe discriminates)
    

    So the pnpm layer-1 result — pnpm forwards the bare separator verbatim — is true and does not imply the flags are lost, because this CLI removes the separator itself before oclif sees it. That is exactly the inference my triage warned against making, and I am glad it was tested rather than reasoned.

    The control pair is decisive because it uses the verdict line rather than the exit code (both legs exit 2):

    os whoami --this-flag-does-not-exist      →  Nonexistent flag: --this-flag-does-not-exist
    os whoami -- --this-flag-does-not-exist   →  Nonexistent flag: --this-flag-does-not-exist   (identical)
    

    whoami declares zero args, so a live separator would have produced "Unexpected argument". It does not. dev agrees, -p resolves to --port through the separator, and the real chain (objectstack dev --seed-admin -- --fresh -p 44637) parses normally.

    Causation proven by ablation, not just correlation: with the hook neutered and rebuilt, the same argv yields Unexpected arguments: -p, 44637, --log-level, bogus — and --fresh is absent from that list, silently absorbed as the package positional. Restore verified byte-identical with a dist preflight confirming the marker gone from all 428 built files. The behaviour is already pinned by strip-arg-separator.test.ts (5/5).

    ⚠️ My escalation overstated the consequence — correcting it here

    I wrote that a dropped -p would mean "every agent starts on the default port." That is wrong on the mechanism. plugin-hono-server retries EADDRINUSE for 20 attempts, so agents would have spread across 3000–3019, not piled onto one port.

    The harm in the counterfactual was real but different in kind:

    1. the first agent squatting :3000, which is reserved for the UI hard-wire; and
    2. --fresh silently eaten, making a run that was supposed to be ephemeral persistent instead.

    Both matter, neither is "five agents fighting over one socket." I escalated a genuine uncertainty on a confident-sounding consequence I had not checked, and the consequence was the part that was wrong.

    Housekeeping — a second orphan branch for the maintainer

    ⚠️ claude/issue-11669-dev-separator-reaches-oclif remains on the remote at 88b9d749a, zero commits, identical to origin/main. This container's credentials permit push but refuse delete, same 403 as claude/issue-10901-declinedin-discriminators. Needs a maintainer to remove. (The dev also corrected its own earlier claim that it had been deleted — right call.)

    Port hygiene held throughout: every probe died in the parser, nothing bound a port, nothing was killed, ss -ltn showed 0 listeners on the probe port. That matters on a box running five agents, and on a card about port discipline.

    NOT MEASURED, correctly declared: the port auto-shift under real contention (adapter.ts:1179-1203) was read from source rather than booted, because measuring it needs two concurrent servers.

    Closing completed. pm:dispatched stripped. This question was raised on 2026-08-21 and dropped; it now has a measured answer on the record so it is not asked a third time.


    Generated by Claude Code

  6. removed their assignment
    on Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions