Repository navigation
fix(cli): parse options per resolved command so -p means --path again - #370
Merged
Merged
Conversation
argv was parsed once against the union of every command's options keyed by long name, so the last declaration of a short flag won everywhere: `-p` became `ssh --port` for every command that declares `--path -p`, and run.ts refused `hack up -p <dir>` with `Option(s) not valid for "up": --port`. Parse in two passes instead. The first resolves the command path (and the child-command boundary) reading each option with the options of the command resolved so far; the second parses with that command's options plus the global and built-in ones. Options only other commands declare are still parsed by long name so they keep their "not valid for <command>" refusal; their short flags never leak. The per-scope guard test replaces the cross-tree type guard. Refs HACK-1293 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Found 2 test failures on Blacksmith runners: Failures
|
This was referenced Oct 21, 2026
roodboi
marked this pull request as ready for review
October 22, 2026 00:46
roodboi
added a commit
that referenced
this pull request
Oct 22, 2026
…hase (#375) ## Summary Fix unit G30: native e2e and storage budgets (HACK-1324, HACK-1320, HACK-1321). ### HACK-1324: storage preparation gets its own named budget Root cause, confirmed on three hosted first attempts (#365 twice, #370): - Every storage carrier took its absolute deadline from the start budget (`Date.now() + resolveComposeStartupTimeoutMs()` in `src/lib/native-compose-command-storage.ts`). `native-config-down-hooks` runs with `HACK_COMPOSE_STARTUP_TIMEOUT_MS=15000`, so its first storage carrier had 15 s for the dependency check, target observation and helper create/policy/start. On loaded Blacksmith runners preparation alone takes longer, and the scenario failed at 18.3-18.5 s with `storage-target`, `storage-helper-*`, `probe-timeout`/`probe-cancel`/`unclassified`. - In `native-config-build`, about a dozen full storage proofs run in the build and pre-spawn guards (`fresh()` in `buildNativeComposeImages`, then `runNativeComposeOwnedFileChild`). They consumed the 90 s start deadline, so the spawn admission refused (`compose-child` / `private-uncertain`, run 39676161586) or the outcome was `E_STARTUP_INCOMPLETE` (run 39663510147). Changes: - `HACK_NATIVE_STORAGE_TIMEOUT_MS` (`resolveNativeStorageTimeoutMs`, default 90 s, range 1,000-600,000 ms) bounds each storage carrier. It is never derived from the start budget. Default behavior on an unset host is unchanged (the old start default was also 90 s). - `createNativeComposeStartDeadline`: guard storage proofs during a native `up`/`restart` are excluded from the start budget; their monotonic elapsed time extends the start deadline. Overlaps count once, rejected proofs still count, the deadline never moves earlier, and each excluded proof stays bounded by its own carrier budget. Build and spawn admission read the live deadline. - Whole-phase cap on excluded storage time (7daca0d): the total excluded from one `up`/`restart` start deadline may not exceed 16 x `HACK_NATIVE_STORAGE_TIMEOUT_MS` or 1 hour, whichever is smaller (24 min at the 90 s default; 1 h from S = 225 s up). The deadline moves at most that far; a guard proof that would start past the cap is refused before it runs; one that ends past it refuses the phase with a closed, value-free `NativeStorageTotalBudgetError` (effect reason `storage-budget`, stage `guard-storage`). The phase ends within the start budget + the cap + at most one in-flight guard proof. - Closed effect reason `storage-budget`: a refusal that finds its carrier's own deadline passed (not cancelled) names the budget; the human summary names `HACK_NATIVE_STORAGE_TIMEOUT_MS`. - Scenario budgets: build hack commands 120 s -> 300 s, down-hooks 90 s -> 240 s. Both scenarios now log per-command duration plus closed storage phase timings from the passive phase trace, so hosted runs record measured preparation durations. No check, ownership fence, retention or recovery rule changed; per-probe caps (10-15 s) are unchanged. ### HACK-1320: networks whole-run budget - Named `NATIVE_NETWORK_FIXTURE_SCENARIO_TIMEOUT` = 90 min. 44 measured hosted passes took 2113-3494 s (median ~2650 s); this PR's own run hit 3494 s, 97% of the old 60-minute budget. 90 min keeps 1.55x the slowest pass; with cleanup and two command bounds the hard bound stays inside the 150-minute shard job. - Whole-run and cleanup budgets now gate only the start of a command; a started command keeps its full 180 s. Run 39431629301 failed because the remaining whole-run budget clipped and SIGKILLed an in-flight `down` carrier command, leaving a created helper that saved `down --recover` correctly refuses to repair. ### HACK-1321: M6 and monotonic group absence - The unrecorded-command group-absence deadline is now monotonic (`performance.now`), named `UNRECORDED_GROUP_BUDGET_MS` (3 s). - New fake-clock test pins the `probe-budget` refusal on the no-record path. - Carrier doc lists the fixed 10-15 s probe caps. ### Release note - Storage carriers no longer follow `HACK_COMPOSE_STARTUP_TIMEOUT_MS` in either direction (a short start budget no longer shortens them, a long one no longer lengthens them). Hosts that set `HACK_COMPOSE_STARTUP_TIMEOUT_MS` should also review `HACK_NATIVE_STORAGE_TIMEOUT_MS`, which now sets both the per-carrier bound and the whole-phase storage cap. ## Evidence ### Rework at 7daca0d (verifier gaps) - Wiring test: `tests/native-compose-start-storage-exclusion.test.ts` runs the real source CLI `up` with a 3 s start budget. The Docker stand-in reads the CLI's own phase trace (stderr written to a file, so it is on disk before the carrier spawns Docker) and delays the carrier dependency check (`image inspect`) by 4 s only while a `guard.storage` span is open. No call counting. About 43 s on a loaded Mac (the earlier prototype took about 80 s). Mutation: with `start.excludeStorage(...)` removed around the guard's `storage.verify`, the test fails (`up` exits 1, the workload is never admitted); restored, it passes. - Cap tests in `tests/compose-startup-budget.test.ts` (fake clocks). Each of four mutations fails them: no post-proof check, no entry check, no `min` on the deadline extension, and the cap error not classified as `storage-budget`. - Docs: `docs/cli.md`, `docs/reference/native-compose-commands.md` and `docs/reference/native-compose-storage-xattr-carrier.md` describe the per-proof bound, the phase cap and how the two variables interact. - Gates: CLI and root typecheck, root `bun run check` (privacy-check ok), biome on the changed files (only an existing complexity warning in `native-compose-command.ts`), gitleaks 8.30.1 over `origin/next..HEAD` (no leaks). - M3 full suite at 7daca0d: 7721 pass, 126 skip, 1 fail (`repository catalog > the checked-in .hack/tasks.json recipes are all reviewed`). It passes locally at the same head and in hosted `test`, and this PR does not touch `.hack/` or the task catalog. I could not rerun it on M3 next because SSH to M3 failed (local agent signing error). - Hosted CI at 7daca0d: all checks pass, including all four docker-e2e shards. ### Measured storage durations (hosted shard 4, this branch, 9 attempts) From the new per-command phase-trace lines: | Measure | Range across 9 attempts | | --- | --- | | Longest single storage proof | 3.6-11.7 s | | Cold enrollment (`storage.enroll`, first `up`) | 11.7-35.0 s | | `native-config-build` first `up` | 50-176 s, of which 16 proofs took up to 145 s | Old behavior on the slowest attempt: the 145 s of guard proofs alone exceed the 90 s start budget, and the 35 s cold enrollment exceeds the 15 s budget the down-hooks scenario gave every carrier. New budgets: 90 s per carrier (2.6x the slowest observed carrier work, 7.7x the slowest proof); build hack commands 300 s (1.7x the slowest `up`); down-hooks hack commands 240 s. ### Hosted CI on this branch (11 attempts across 0af0fb6, 94ce188, cee65eb, 4e2b056) | Scenario | Passes | Durations | | --- | --- | --- | | native-config-build | 11/11 | 116.7-336.9 s | | native-config-down-hooks | 11/11 | 84.9-181.4 s | | native-config-networks | 11/11 | 1766-3494 s | The merged head 4e2b056 (next a25160d merged in) passed both of its full runs (run 39721132414 attempts 1 and 2). Before this change, shard 4 failed on first attempts of #365, #370, #374 and on next itself (39663080763, 39670689406). Other attempt failures, all unrelated to these files: `tests/native-authored-project- start.test.ts` frontend cascade (also seen on 6+ other branches and next), `tests/private-env- file.test.ts` symlinked `.env` ENOENT, and one runtime-core Rust test (`rebind::tests::healthy_fixed_selection_is_noop_and_complete_shared_slot_refreshes_together`). ### Gate vs PR shard-4 comparison Same runner pool and labels (`blacksmith-4vcpu-ubuntu-2404`, runner 2.337.0), identical shard composition and order, no leftover volumes before `native-config-build` (end-of-job cleanup volumes are the failed scenarios' own retained fixtures). The failing jobs (including next push run 39663080763) ran unrelated earlier scenarios 1.3-1.9x slower, so the difference is runner speed, not composition or state. The logs print no `docker info` storage-driver lines, so that dimension could not be compared. ### Mutation proofs (each killed by a focused test) M6 (`groupAbsent` deadline -> plain `refuse()`), M6b (wall-clock deadline), carrier deadline from the start budget, no start-deadline exclusion, nested exclusion double-count, build deadline read once, carrier reason ignores budget, budget reported over cancellation, xattr lifetime unmarked, effect reason ignores mark, summary not naming the variable, networks command clipped to the whole-run remainder. ### Local and M3 gates - `bun run typecheck` and `bun run check`: 0 errors at 4e2b056. - gitleaks 8.30.1 over `origin/next..HEAD`: no leaks. - M3 full suite at 0af0fb6: 7587 pass, 126 skip, 0 fail. - M3 full suite at 4e2b056 (host load 25-39): 7681 pass, 126 skip, 12 fail. 10 of the 12 pass when their files rerun serially; the other 2 (`tests/native-authored-project-recovery.test.ts` no-host death/recovery) fail the same way on plain next a25160d. None touch the changed code. - M3 is not a qualifying host for the native e2e scenarios; hosted CI is the e2e proof. ### Gaps - The whole-phase cap is proven at unit level only. A command-level cap test cannot isolate it, because at S = 1 s the per-carrier budget fires first. - An in-flight guard proof is not aborted when the cap is reached. It finishes within its own per-carrier bound, then the phase refuses. - The networks scenario still SIGKILLs a command that exceeds its own 180 s bound; that genuine hang still leaves storage evidence for explicit recovery. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi
added a commit
that referenced
this pull request
Oct 22, 2026
…ty native run selections (#394) Small CLI follow-ups to #368 and #370 (HACK-1339, and the code parts of HACK-1338; not the M3 host gate). ## Root cause - **Missing project codes differed (HACK-1339 item 1).** `requireProjectContext` threw a plain `MissingProjectContextError`. Only `up`, `down` and `restart` caught it and mapped it to `E_PROJECT_NOT_FOUND`. Every other project command let it reach the top-level handler, which reported it as `E_UNEXPECTED` under `--json` and, without `--json`, printed a stack trace. Before this change: - `hack ps -p <empty dir> --json` and `hack logs -p <empty dir> --json` reported `E_UNEXPECTED`. - `hack up -p <empty dir> --json` and `down` reported `E_PROJECT_NOT_FOUND`. - **Empty native run selections were ambiguous (HACK-1338 item 5).** On the native runtime, `hack run --env ""` resolved to the base env (`null`) and `--profile ""` resolved to no profiles (`[]`). Each was then compared with the running selection. Against a project started with the base env and no profiles, both silently matched and the command ran. Against any other selection, they were refused with the "does not match" message. So an empty value meant different things depending on what was running. ## Fix - `MissingProjectContextError` now extends `HackCliError` with code `E_PROJECT_NOT_FOUND`. Most commands already used that code, and the MCP and target-inspect contracts use it too. `ps`, `logs`, `run`, `exec` and `open` now report the same envelope as `up`, `down` and `restart`. Without `--json` they print one closed `E_PROJECT_NOT_FOUND` line and no stack. The existing `up`/`down`/`restart` catch sites are unchanged. - Native `run`: an empty `--env` or `--profile` is a closed `E_USAGE` usage error. Values that contain only whitespace (`--env " "`) or only commas (`--profile " , "`) count as empty. The check runs in `runBackendSelection`, before the value is resolved, the project is loaded, or any runtime request is made. The error message is exported as `NATIVE_RUN_EMPTY_SELECTION_REFUSAL`: > Native `hack run` does not accept an empty --env or --profile value. Omit the option to use the running selection, or pass the env or profiles `hack up` started with. No command ran. Omitting both options still inherits the running selection. The Compose `run` path is unchanged: there, an empty `--env` still means the base layers. - `requireNativeRunSelection`: removed the redundant `run.effectiveEnvName === undefined ||` clause (item 7). A legacy mapping with no recorded env still refuses: `undefined` never equals a given selection, which is always a string or `null`. The existing legacy-mapping unit test still covers this. - Docs: - `docs/cli.md` has a new "Option parsing" section. It describes per-command parsing and the residual edge (item 3): an option placed before the command name that is a flag in one command and takes a value in another is read as a flag. It also records the parse differences that #370's old-versus-new comparison found (item 4). The only difference was the intended one: `-p <dir>` on a project command now sets `--path`. The section lists the edge cases that parse the same as before. - `docs/cli.md` now states the missing-project code. - `docs/guides/native-candidate.md` states the empty-value rule. ## Tests (each checked by mutation) - New `tests/missing-project-error-code.test.ts`. It spawns the source CLI with `privateCliEnv` and checks two things: - `ps`, `logs`, `up`, `down` and `restart` with `-p <empty dir> --json` all return `{ok:false, error:{code:"E_PROJECT_NOT_FOUND", ...}}`. - `ps` without `--json` prints exactly one `ERROR: E_PROJECT_NOT_FOUND: ...` line and no stack. Mutation: reverting the class to a plain `Error` fails the `ps`, `logs` and non-JSON `ps` tests. - `tests/native-run-selection-cli.test.ts`: - Five new refusal cases: `--env ""`, `--env=`, `--env " "`, `--profile ""` and `--profile " , "`. They run against a project started with the base env and no profiles, where the old code would have run the command. Each must print one closed `E_USAGE` line and make no runtime call. - One control case: omitting both options still runs. Mutations: - Dropping the env check fails 3 tests. - Matching only a literal `""` profile fails the comma case. - Not trimming the env value fails the whitespace case. - Always refusing fails the 6 existing match and mismatch tests. - `tests/cli-per-command-options.test.ts` (item 2): I mutated each branch of `scanInvocation`, `scanPositional`, `scanOption`, `consumesNextToken`, `omitsOptionValue` and `representativeDeclarations`, then ran the parser suites against each mutant. Two non- equivalent mutants survived, and I added a test for each: - **A lone `-` is a positional, so it can start the child command.** Mutation: treating `-` as an option. - **After a command's own positional, a subcommand name is the child command.** This uses a synthetic passthrough command that also has a subcommand. Mutation: not clearing `subcommands` after a positional. Both new tests fail under their mutation. Two mutants still survive, and both are equivalent: - Removing the `includes("=")` early return in `scanOption`: no long or short key can contain `=`. - Continuing the scan after `"done"`: no subcommands remain, and no passthrough index is set. - `tests/cli-closed-error-output.test.ts` (item 6): - The handler-usage-refusal test used the removed "restart with the requested selections first" string. It now uses the `run` handler's current `CliUsageError` ("Missing required argument: service"). - A new test renders the current `NATIVE_RUN_SELECTION_REFUSAL`, raised by the real `requireNativeRunSelection`, as one coded `E_USAGE` line with no hint. - The `--json` usage test's stale "Native exec does not support --env." text is updated to the current exec refusal. ## Evidence - Bun 1.4.2 (`mise x bun@1.4.2`), base `origin/next` 16f4547 (which includes a25160d). - `bun run --cwd packages/cli typecheck`: 0 errors. Root `bun run check` (includes `privacy:check: ok`): 0 errors. The changed files pass `biome check` cleanly. - Focused suites, run locally: 176/176 across `cli-per-command-options`, `cli-closed-error- output`, `native-run-selection-cli`, `missing-project-error-code`, `native-project-one-off`, `cli-passthrough-argv`, `cli-command`, `project-up-command`, `lifecycle-json`, `project-run- command`, `native-project-exec`, `cli-reference-docs` and `output-hygiene`. Also 44/44 across `env-key-argument-redaction-cli`, `cli-renderer-loading`, `integration-ownership` and `mcp`. - `bun run docs:cli-reference`: no change to `docs/reference/cli.md`, because the help surface is unchanged. No consumer agent guidance changed, so I did not regenerate the plugins. No `package.json` scripts changed, so nothing needed re-pinning. - gitleaks 8.30.1 (`gitleaks dir` over a `git log -p origin/next..HEAD` export): no leaks. - Full suite on the M3 at exactly 07306f2 (detached worktree, Bun 1.4.2, after `bun run build:config-compiler`): 7691 pass, 126 skip, 4 fail, across 549 files in 2750.78s. None of the 4 failures touch this change, and the same 4 also fail on unrelated branches run on the same M3: - `repository catalog > the checked-in .hack/tasks.json recipes are all reviewed`. On the M3 it fails with `E_USAGE` `detached_checkout` ("Detached linked worktree cannot select an instance implicitly"), because the M3 harness checks out a detached worktree. It passes locally on a branch checkout at this head. It also failed this way in the F20r3, G35, G36 and G41 runs. - Three native recovery tests in `native-authored-process-incarnation` and `native-authored- project-recovery` ("original capture uses its live session…", "new no-host death uses real read-only quiescence facts…" and "new no-host recovery retains original bindings on last- await"). These are timing-dependent. The identical set failed in the F20r3 run, and they also show up in the G35 and G41 runs. - I queued serial reruns of these files with `--timeout 60000` at this head. The shared M3 CI lock stayed busy for over 100 minutes, so I cancelled the reruns before they got the lock. No lock or worktree was left behind. - Focused suites on a trial merge with the current `next` (8e53df3), which merges cleanly: 68/68 across `missing-project-error-code`, `native-run-selection-cli`, `cli-per-command- options`, `cli-closed-error-output`, `project-run-command` and `lifecycle-json`. - Hosted CI at 07306f2: every job passes except docker-e2e shard 3/4. That shard failed in `pattern-p01-dependency-bootstrap` with `docker inspect <id>`, then `Error: No such object`: the fixture container disappeared between listing and inspect. That scenario does not use the code this PR changes. I did not re-run the job. ## Gaps - The empty-value rule covers only the native one-off path (`HACK_RUNTIME_BACKEND=native`, `nativeProjectOneOff`). Two paths are unchanged: - The authored native-Compose `run` path (`tryNativeComposeCommand`), which prepares a new generation, still resolves `--env ""` as the base env. - Compose `run`, where an empty `--env` still means the base env. - The P02 example README was not changed, to avoid churning the pattern fingerprints. The native guide carries the rule. - The M3 host gate in HACK-1338 is out of scope. Refs HACK-1339 Refs HACK-1338 🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi
added a commit
that referenced
this pull request
Oct 23, 2026
…t commands (#427) Follow-ups to #394 (merged into `next` as d0c8184) for HACK-1371. ## Rework (4d30eff): hosted `test` failure on e3d6917 **Root cause.** Fix 4 below moved the empty `--env`/`--profile` check ahead of authored dispatch, so `hack run --profile ""` on an authored native project now refuses with `E_USAGE`, not `E_NATIVE_PROJECT_UNSUPPORTED`. The source CLI run table in `tests/native-authored-project- oneoff.test.ts` still expected `E_NATIVE_PROJECT_UNSUPPORTED` for the `["--profile", ""]` row. That failure also set the file's `uncertain` latch, so 4 later tests in the file failed as well (`--profile dev`, `--env base`, "public cancellation after run completion returns130…", and "unconfirmed run completion maps to the closed run refusal"). Hosted run 39846392499 reported 5 failures. My earlier focused run (33 files) did not include this file. **Fix (test only).** Each row of that table now carries its expected refusal: - The `--profile ""` row expects `ERROR: E_USAGE: ${NATIVE_RUN_EMPTY_SELECTION_REFUSAL}` and no runtime attempt. - The `--profile dev` and `--env base` rows keep `E_NATIVE_PROJECT_UNSUPPORTED` and no runtime attempt. No source file changed. **Proof.** - `bun test tests/native-authored-project-oneoff.test.ts`: 50 pass, 0 fail. Before the fix: 45 pass, 5 fail. - Mutation: I swapped in `src/commands/project.ts` from `origin/next` (old dispatch order), and the `--profile ""` row failed. The two later rows then failed through the existing latch, as designed. I restored the file afterwards. - I searched the other tests for an empty `--env`/`--profile` expecting `E_NATIVE_PROJECT_UNSUPPORTED`. Only the `logs` and `exec` tables do that, and the early refusal applies only to `run`. `native-authored-project-logs` and `native-authored-project- exec` pass 91/91. ## Root cause (original commit e3d6917) 1. **Commands still reported a missing project with different codes.** #394 gave `project.ts` a closed `MissingProjectContextError`, but other command files kept their own copy of `requireProjectContext`. For `hack <cmd> -p <empty dir>`: - `branch add/list/remove/open`, `config get/set` and `project owner show` threw a plain `Error`. They printed `ERROR: Error: ...`, a stack trace, and a stray `└` line on stdout. `project owner show --json` reported `E_UNEXPECTED`. - `env ...` and `host exec/shell` threw `CliUsageError("No .hack/ found. Run: hack init")`. That reported `E_USAGE` and added a "See --help" hint. - `tui` (with a TTY) threw a plain `Error`. `docs/cli.md` said every project command reported `E_PROJECT_NOT_FOUND`, which was not true. 2. **The docs gave the wrong refusal for a short spelling.** `docs/cli.md` said an option that only another command declares is refused as `Option(s) not valid for "<command>"`. That is true only for long spellings. The per-command parser (#370) reads another command's option only by its long name, so a short spelling the command does not declare is unknown to it. For example, `hack doctor -f --json` gives `Unknown option '-f'. ...`. The old union parser gave `Option(s) not valid for "doctor": --follow` instead, and the #370 differential notes did not record that difference. 3. **A mutant survived.** Changing `.every(...)` to `.some(...)` in `requireNonEmptyNativeRunOptions` survived, because no test passed a profile list that mixes empty and real entries. 4. **Authored projects got the wrong refusal.** On the native backend, `hack run` ran authored dispatch (`tryNativeComposeCommand`) before the empty check. So an authored `hack.project.json` project refused `--profile ""` with `E_NATIVE_PROJECT_UNSUPPORTED` instead of `E_USAGE`. ## Fix 1. I changed the commands to match the doc instead of narrowing the doc. A new `src/lib/missing- project-error.ts` exports `MissingProjectContextError` (`E_PROJECT_NOT_FOUND`, message `No .hack/ (or legacy .dev/) found. Run: hack init`). `project.ts`, `branch.ts`, `config.ts`, `env.ts` (which covers `host`), `project-admin.ts` and `tui.ts` now throw it. `docs/cli.md` lists the commands and the documented exceptions: - `tui` without a TTY refuses for that reason first. - `env get` prints one fixed stderr line, because its output is raw values. - `config validate/plan/import/adopt` are native configuration commands and keep their own refusals. - `project target/task` use the same code with their own message. - `init`, `doctor`, `crash-capture`, `setup`, `mcp` and `agent` do not need a project. 2. I documented the short-spelling refusal in "Option parsing", and added it as a second difference in the #370 old- versus-new notes. I checked the old behaviour by running the pre-#370 tree (a25160d^) with the same arguments. Making the two match would mean reading other commands' short flags again, which brings back the `-p`/`-d` conflict that #370 removed. So I documented the difference instead. 3. No source change; new tests pin the behaviour (see Tests). 4. I moved the empty check earlier, into `refuseEmptyNativeRunSelection`, which `handleRun` calls first when `HACK_RUNTIME_BACKEND=native`. Authored, adopted and legacy projects now all refuse an empty value with the same `E_USAGE` line, before any project is selected. The Compose backend is unchanged: there an empty `--env` still means the base layers. `docs/guides/native-candidate.md` states which check runs first and the `web,` rule. `docs/reference/cli.md` is unchanged, because `bun run docs:cli-reference` produced no diff (the help output did not change). ## Tests (each proven by mutation) - `tests/missing-project-error-code.test.ts` was rewritten: - A table classifies every command that declares `--path`, and one test asserts that the table equals the set of commands walked from `CLI_SPEC`. A new `--path` command therefore fails until someone classifies it. Mutation: dropping the `crash-capture` row fails the enumeration test. - Each project command is spawned against an empty dir (`privateCliEnv`, temp HOME) and must print the exact envelope or line, with no stack trace. The documented exceptions are pinned to their exact output. - Mutations: restoring the `origin/next` version of `branch.ts` fails 4 tests, `config.ts` fails 2, `env.ts` fails 12 (env and host), and `project-admin.ts` fails 1. - `tests/cli-renderer-loading.test.ts`: the `tui-missing-project` control (forced TTY) now expects `ERROR: E_PROJECT_NOT_FOUND: No .hack/`. Mutation: restoring the `origin/next` version of `tui.ts` fails it. - `tests/cli-per-command-options.test.ts`: every short spelling that some other command declares but `doctor` does not (`-f`, `-d`, `-c`, `-H`, `-u`, `-t`) throws `Unknown option '<short>'` on `doctor`. `doctor --follow` still parses, so the CLI can say it is not valid. Mutation: letting `optionTableFor` also read other commands' short spellings fails it. - `tests/native-run-selection-cli.test.ts`: - `--profile "toolchain,"`, `",toolchain"` and `" , toolchain , "` run against a project started with `toolchain`. - `--profile "web,"` against `toolchain` gets the mismatch refusal, not the empty one. - Mutation `.every` to `.some`: all 4 fail. - `tests/native-authored-command-dispatch.test.ts`: on an authored project with the native backend, `run` with `--profile ""`, `--profile " , "`, `--env ""` or `--env= ` prints exactly `ERROR: E_USAGE: <NATIVE_RUN_EMPTY_SELECTION_REFUSAL>`, and neither the compiler nor the runtime runs. Mutation: disabling the early check fails all 4 (they report `E_NATIVE_PROJECT_UNSUPPORTED`). - `tests/native-authored-project-oneoff.test.ts`: the source CLI run table, as described in Rework. ## Evidence - Bun 1.4.2 (`mise x bun@1.4.2`). Base `origin/next` d0c8184, which includes a25160d. - At 4d30eff: `bun run --cwd packages/cli typecheck` has 0 errors. Root `bun run check` has 0 errors and prints `privacy-check: ok`. `biome check` is clean on the changed test file. - Focused suites at 4d30eff, run on this Mac: - The 6 changed test files: 155/155 pass. - `native-authored-project-logs` and `native-authored-project-exec`: 91/91 pass. - An earlier run at e3d6917 passed 436/436 across 33 files: the branch, config, env, host, project owner/target/task, MCP, lifecycle-json, CLI parsing, help and reference-docs, renderer loading, closed-error output, native run selection, native one-off and authored dispatch suites. That run did not include `native-authored-project-oneoff`, which is how the failure got through. - `bun run docs:cli-reference`: no change. No consumer agent guidance or `package.json` scripts changed. - gitleaks 8.30.1 (`gitleaks dir` over a `git log -p origin/next..HEAD` export of both commits): no leaks. - Hosted CI at 4d30eff (run 39854071300): the `Tests (Turbo)` step of the `test` job passed. That step runs the full suite on a macOS runner. Every other job that had finished when I wrote this also passed; see the checks tab for the final state. - Full suite on the M3: **not run.** At 17:01 I queued it at exactly 4d30eff behind the shared `m3-ci/.lock`. M3 had about 67 GiB free then. The lock was held first by G41, then from 17:39 by G52, whose 10-round loaded loop was still running at 20:28. About a dozen other units were also waiting, some for over 9 hours. I did not break a lock I don't own. At 20:30 I stopped my own waiter, which never took the lock and never created a worktree or ref. The hosted `Tests (Turbo)` full-suite step passed at this exact head. ## Gaps - The M3 full suite has not run at 4d30eff, because the shared lock queue never reached it (see Evidence). The hosted macOS `Tests (Turbo)` step ran the full suite at this head and passed. - On the Compose backend, an authored native-Compose `run` still resolves `--env ""` as the base env. This is unchanged and documented. - `remote` and `gateway` (unsupported) and `internal` keep their own missing-project errors. - `tui` with a real TTY is covered only by the renderer-loading fixture, which forces `isTTY`. Refs HACK-1371 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
![Fix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/fix-with-codesmith-light-3x.png)
Root cause
parseCliArgvparsed argv once against the union of every command's options, keyed by long name (buildUnionParseOptions). Two options shared a short flag:--path -p(project commands) andssh --port -p. Bun'sparseArgsresolved-pto whichever came last, which wasssh --port. Sohack up -p <dir>(andinit,down,ps,logs,env ...,config ...,setup ...) parsed as--port, andrun.tsrefused it withOption(s) not valid for "up": --port. The same union let one long name's type in one command change how every other command parsed it (#339 hit this with--rollback), which is why a guard test banned cross-command type conflicts.Fix
src/cli/command.tsnow parses in two passes:scanInvocationresolves the command path, and the child-command boundary for passthrough positionals, from the leading positionals. It reads each option with the options of the command resolved so far, plus the global and built-in options, and skips that option's value token, including the omitted-value form of options with a default. An option placed before the command name falls back to the other declarations of that spelling. If those declarations disagree about taking a value, the option is read as a flag, so it never consumes the command name.parseCliArgvthen parses with that command's own options plus the global and built-in options, by long and short spelling. Options that only other commands declare are still parsed by long name, never by short flag. A misplaced option therefore keeps its existingOption(s) not valid for "<command>"refusal.findPassthroughStartandhackOwnedArgvuse the same scan, so #359's child-argv passthrough uses the resolved command's option types. That covers--handling, flags after the child command, and the JSON envelope selected only by Hack's own--json. Default-value normalization is scoped the same way.run.tsis unchanged.The only parse difference found by comparing old and new output on edge cases (
--help --all,help --all,-- up,--json run web tool --json,--path /x up,logs --tail,up --tail,ssh --path /x,x ext cmd -p 1,--no-interactive up -p /x) is the intended one:up -p /xnow setspathinstead ofport.Tests
tests/cli-per-command-options.test.ts:-psets--pathfor every command that declares--path -p. The test walks the spec and asserts that it includes init/up/down/ps/logs/env list/config validate/setup migrate.ssh -psets--port.-pbefore the command name follows that command.config adopt --rollback --jsonstays boolean, andsetup migrate --rollback-manifest <file>takes a value.--rollback/-ris a flag in one command and takes a value in another parses correctly per command, before the command name, and at the passthrough boundary.privateCliEnvchecks thathack ps -p <dir> --jsonno longer reports--port.tests/cli-command.test.ts: the cross-tree "one value type per long option" guard is replaced by a per-scope guard. Within a command's scope (built-ins, globals and the command's own options), each long and short spelling must name exactly one option.tests/cli-passthrough-argv.test.tsare unchanged and pass.Each new test was checked by mutation:
command.ts, 11 of the new tests fail, including the spawn test. It printedOption(s) not valid for "ps": --port.ssh --portthe short flag-hfails the per-scope guard withhack ssh: -h names --help and --port.Evidence
Bun 1.4.2 (
mise x bun@1.4.2), base ace57ee.nexthas since moved to f2278c7; it only adds an unrelatedrun.tshunk and merges cleanly.bun run --cwd packages/cli typecheck: 0 errors.bun run typecheck: passes.bun run --cwd packages/cli checkandbun run check: 0 errors. The changed files lint clean, with no new warnings.mcp.test.tsspawn test under load; it passed alone and in the rerun.bun run docs:cli-reference: no change todocs/reference/cli.md, because the help surface is unchanged.bun run build,dist/hack ps -p <dir> --jsonanddist/hack up -p <dir> --json(private HOME) reach project discovery ("No .hack/ found") instead of the--portrefusal.git log -p origin/next..HEADexport: no leaks.Remaining gaps
bun run testran on a heavily loaded host for 4253s and ended with 6824 pass and 640 fail across 105 files. Rerun serially with--timeout 60000, 89 of those files pass.origin/next'scommand.tsswapped in, they fail the same way or worse; for example,native-compose-adoption-generationfails 0/111 on both.dependency-startup-progress,native-compose-adoption-probe) pass alone, and neither imports the CLI parser.scanInvocation: an option placed before the command name whose spelling is a flag in one command and takes a value in another is read as a flag. No such spelling exists in the current CLI.Refs HACK-1293
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.