# Feature: Package Manager Resolution

**Pattern**: the node-shaped arms of Phase 3 and the verify-by-test loop typed
`npm` into the command line. A repo on pnpm, yarn or bun then gets one of two
outcomes, both bad: the command fails outright, or npm resolves against a lock
file it does not own and the run continues on a tree the repo's own tooling
would never have produced. Either way it happens in Phase 3, with a worktree and
a branch already created - the failure shape `docs/adr/0012-macos-only.md`
rejected for platforms.

`$HOME/.claude/scripts/package-manager.mjs` resolves it from the repo. Node core
only (ADR-0004): no corepack call, no spawn, no network - a resolver that shelled
out would need a working install of the very tool it is identifying. Asserted by
`smoke-package-manager.sh` and `test/package-manager.test.mjs`.

## 1. Resolution order

| # | Evidence | Reported `source` |
|---|---|---|
| 1 | `$MA_PACKAGE_MANAGER` | `env` |
| 2 | `package.json` `"packageManager"` (corepack's own field) | `packageManager-field` |
| 3 | a lock file (`pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`/`bun.lock`, `package-lock.json`, `npm-shrinkwrap.json`) | `lockfile` |
| 4 | npm | `default` |

What the repo **said** outranks what the repo **left behind**: a stale lock file
outlives a migration and a declaration does not. The default is reported AS a
default, never as evidence - "npm because nothing said otherwise" and "npm
because the repo committed a package-lock" are different answers to the same
question, and only one of them is safe to act on twice.

The walk goes upward from the given directory and stops after the directory
holding `.git`. A monorepo keeps its lock file at the root while the task edits a
package three levels down, so stopping at the starting directory would resolve to
the default for most real repos; going past the repo root would let a stray
`yarn.lock` in a home directory decide how somebody's project builds.

**Two lock files** means a migration left one behind. The newest wins and BOTH
are reported (`source: lockfile-newest`, `ambiguous: [...]`): silently picking one
of two committed lock files is how a repo ends up building with the manager it
migrated away from.

## 2. The command lines

- **`run` for every manager**, always: `pnpm build` and `yarn build` work only
  until a script shares a name with a builtin (`test`, `add`, `install`), and
  then the builtin wins and the repo's own script never runs.
- **Only npm needs `--`** before pass-through arguments. Adding it for the others
  hands the test runner a literal `--` to ignore.
- **`bun run test`, never `bun test`**: the latter is bun's own runner and would
  ignore the script the repo declared.
- **No `--frozen-lockfile` / `--immutable`**: that is a CI decision, not ours.
- **Never `eval "$(pm ...)"` on its own.** Exit 3 empties the command
  substitution, and `eval ""` SUCCEEDS - so a repo with no build script would
  report a build that never ran, which is the failure this feature exists to
  stop, wearing different clothes. Capture first, then eval on success:
  `CMD=$(... ) && eval "$CMD" || echo "no build script"`.
- **Exit 3 means the repo declares no such script.** That is the `--if-present`
  case, answered by an exit code rather than by a flag whose support differs per
  manager. The caller skips the step and says so; it never substitutes a
  different command.

## 3. The resolved name goes through `eval`

The phase runs the printed line through `eval`, so the name is held to the shape
a manager's binary actually has (`^[a-z][a-z0-9-]*$`). A `packageManager` field
or an `MA_PACKAGE_MANAGER` value that does not match is dropped with a warning
and the resolution continues from the repo's own evidence. `smoke-package-manager.sh`
proves this the only way that counts: it evals the produced line with every real
manager stubbed out and asserts the crafted payload did not run.

An unknown but well-formed name (`deno`, say) resolves and is reported with
`known: false`, so the caller can say WHICH unrecognised manager it saw instead
of quietly falling back to npm.

## 4. What is out of scope

iOS and Android are untouched: `xcodebuild` and `./gradlew` are not package
managers and nothing about this changes them. Installing dependencies is not
automated either - `installCommand()` exists for a caller that has decided to
install, and no phase calls it today.
