Skip to content

fix(cli): parse options per resolved command so -p means --path again - #370

Merged
roodboi merged 1 commit into
nextfrom
wf/g19-per-command-options-b21
Oct 22, 2026
Merged

roodboi merged 1 commit into
nextfrom
wf/g19-per-command-options-b21

Conversation

@roodboi

@roodboi roodboi commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Root cause

parseCliArgv parsed 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) and ssh --port -p. Bun's parseArgs resolved -p to whichever came last, which was ssh --port. So hack up -p <dir> (and init, down, ps, logs, env ..., config ..., setup ...) parsed as --port, and run.ts refused it with Option(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.ts now parses in two passes:

  1. scanInvocation resolves 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.
  2. parseCliArgv then 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 existing Option(s) not valid for "<command>" refusal.

findPassthroughStart and hackOwnedArgv use 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.ts is 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 /x now sets path instead of port.

Tests

  • New tests/cli-per-command-options.test.ts:
    • -p sets --path for 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 -p sets --port.
    • -p before the command name follows that command.
    • A misplaced foreign long option is still refused as "not valid here", while an unknown short flag is unknown.
    • config adopt --rollback --json stays boolean, and setup migrate --rollback-manifest <file> takes a value.
    • A synthetic CLI where --rollback/-r is a flag in one command and takes a value in another parses correctly per command, before the command name, and at the passthrough boundary.
    • A source-CLI spawn under privateCliEnv checks that hack ps -p <dir> --json no 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.
  • The fix(cli): pass child flags through and print closed native refusals #359 passthrough tests in tests/cli-passthrough-argv.test.ts are unchanged and pass.

Each new test was checked by mutation:

  • With the old command.ts, 11 of the new tests fail, including the spawn test. It printed Option(s) not valid for "ps": --port.
  • Making disagreeing declarations keep the last type fails the before-command-name test.
  • Removing other commands' long names from the parse table fails the "not valid here" test.
  • Giving ssh --port the short flag -h fails the per-scope guard with hack ssh: -h names --help and --port.

Evidence

Bun 1.4.2 (mise x bun@1.4.2), base ace57ee. next has since moved to f2278c7; it only adds an unrelated run.ts hunk and merges cleanly.

  • bun run --cwd packages/cli typecheck: 0 errors. bun run typecheck: passes.
  • bun run --cwd packages/cli check and bun run check: 0 errors. The changed files lint clean, with no new warnings.
  • Focused suites pass: 131/131 for the new test file, cli-passthrough-argv, cli-command, cli-spawn-env-isolation, cli-reference-docs, integration-ownership, mcp and mcp-inspect-tools. One run had a transient failure in an mcp.test.ts spawn test under load; it passed alone and in the rerun.
  • bun run docs:cli-reference: no change to docs/reference/cli.md, because the help surface is unchanged.
  • After bun run build, dist/hack ps -p <dir> --json and dist/hack up -p <dir> --json (private HOME) reach project discovery ("No .hack/ found") instead of the --port refusal.
  • No native refusal codes or messages changed, so the native plan-CLI scripts were not rerun.
  • gitleaks 8.30.1 over a git log -p origin/next..HEAD export: no leaks.

Remaining gaps

  • Full bun run test ran 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.
    • The 16 that still fail are native-compose/native-authored files. Rerun with origin/next's command.ts swapped in, they fail the same way or worse; for example, native-compose-adoption-generation fails 0/111 on both.
    • Two files with a single failure each (dependency-startup-progress, native-compose-adoption-probe) pass alone, and neither imports the CLI parser.
    • None of these failures is caused by this change. A green full run on a quiet runner or CI is still outstanding.
  • Edge case, documented in 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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

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>
@linear-code

linear-code Bot commented Oct 21, 2026

Copy link
Copy Markdown

HACK-1293

@blacksmith-sh

blacksmith-sh Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
native-config-build (232.5s) — Native --profile must succeed/
native-config-build (232.5s) — Native --profile must succeed
View Logs
native-config-down-hooks (17.6s) — Each finite down mode starts a ready owned generatio
n/
native-config-down-hooks (17.6s) — Each finite down mode starts a ready owned generatio
n
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@roodboi
roodboi marked this pull request as ready for review October 22, 2026 00:46
@roodboi
roodboi merged commit a25160d into next Oct 22, 2026
28 of 32 checks passed
@roodboi
roodboi deleted the wf/g19-per-command-options-b21 branch 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant