fix(setup): record the session-start hook as a search-path name (task 0043)
All checks were successful
CI / test (22) (pull_request) Successful in 47s
CI / test (true, 24) (pull_request) Successful in 1m6s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 2s

The SDK records a bare, upgrade-stable binary name only when a PATH entry
realpath-matches the execPath it is handed. From the module-relative
entrypoint that can only succeed under npm, which symlinks its bin entry
straight at it; a wrapper-based install never can, because a script that
invokes a file does not resolve to that file. So every wrapper install --
Nix, a shim, a generated .cmd -- recorded an absolute path that moves on
upgrade, and a session-start hook that cannot execute fails silently.

`setup hooks` now resolves gitea-axi on PATH itself and hands that
location to the SDK, so the bare name is recorded. A candidate qualifies
only if it resolves to the running entrypoint -- by realpath for a
symlink, or by naming it in its text for a wrapper, following the chain,
since a Nix install is two hops. A same-named binary that is some other
program does not qualify, and the absolute entrypoint path stands as the
fallback exactly as before.

The related defect on the same line goes too: the SDK recognises its hook
by finding the marker inside the recorded command, so an entrypoint path
without "gitea-axi" in it made a re-run append a duplicate rather than
update in place. `setup hooks` now prunes duplicates by matching the
exact command it records, which is independent of that command's shape
and cannot mistake another tool's hook for its own.

With the coupling gone, package.nix no longer renames its build tree; the
fast tier runs from /build/source and its idempotency test passes there.
The help text's instruction to re-run hooks after an upgrade is deleted,
having become false.

Verified against the built Nix binary and a globally npm-installed pack:
both record the bare name, both fall back to the absolute path when the
name is absent, an impostor on PATH is refused, and re-runs leave one
entry. Decision recorded as ADR 0019; ADR 0009's addendum is amended.
This commit was merged in pull request #52.
This commit is contained in:
2026-07-20 13:12:40 -04:00
parent 4e92dde4e4
commit 27aad04984
10 changed files with 750 additions and 49 deletions

View File

@@ -26,9 +26,43 @@ Once the coupling is gone the rename has no remaining purpose and goes with it.
## Acceptance criteria
- [ ] The recorded hook command is the bare binary name whenever that name resolves to the running program on `PATH`.
- [ ] The recorded hook command remains the absolute entrypoint path when the binary is not resolvable on `PATH`, and that fallback is exercised by a test.
- [ ] Re-running `setup hooks` updates the existing entry in place rather than appending a second one, including when the entrypoint path does not contain the marker.
- [ ] The `setup` help text no longer instructs the user to re-run hooks after an upgrade, that instruction having become false.
- [ ] The derivation no longer renames its build tree, and the build still passes with the tree at a path that does not contain the marker.
- [ ] The behaviour is verified against a real wrapper-based install, not only against a source checkout.
- [x] The recorded hook command is the bare binary name whenever that name resolves to the running program on `PATH`.
- [x] The recorded hook command remains the absolute entrypoint path when the binary is not resolvable on `PATH`, and that fallback is exercised by a test.
- [x] Re-running `setup hooks` updates the existing entry in place rather than appending a second one, including when the entrypoint path does not contain the marker.
- [x] The `setup` help text no longer instructs the user to re-run hooks after an upgrade, that instruction having become false.
- [x] The derivation no longer renames its build tree, and the build still passes with the tree at a path that does not contain the marker.
- [x] The behaviour is verified against a real wrapper-based install, not only against a source checkout.
## Implementation Notes
The decision is recorded as [ADR 0019](../adr/0019-hook-records-search-path-name.md).
ADR 0009's addendum claimed the SDK registers the bare binary as the hook command, which held only for npm; it is amended in place.
The spec's "Resolved verification item" section, which concluded the bare name was unreachable through a wrapper, is rewritten to record that task 0043 superseded it.
### Resolving the name had to be stricter than first written
The first cut accepted any executable file named `gitea-axi` on `PATH` and handed it to the SDK.
That satisfied the letter of the change — the SDK's realpath test passed and the bare name got recorded — but only because the path handed over trivially matched itself, which made the SDK's check a tautology rather than a use of it.
Criterion 1 asks for the name to resolve *to the running program*, and that version would have recorded a bare name for a different `gitea-axi` shadowing this one on `PATH`.
`resolveEntrypointOnPath` therefore requires the candidate to be either a symlink whose realpath is the entrypoint (npm's shape) or a wrapper that names the entrypoint in its text (the generated shape).
Driving the real Nix binary showed the wrapper case is two hops, not one: `bin/gitea-axi` sets `PATH` and execs `bin/.gitea-axi-wrapped`, and only that second script names the entrypoint.
Containment follows the chain, bounded by hop, file-count and file-size caps so a dense chain cannot run away, and falls back to the absolute path wherever it cannot reach the entrypoint.
### Recognising the tool's own hook
The SDK's `isManagedHook` is a substring test against the recorded command and is not ours to change, so `setup hooks` prunes duplicates itself after the SDK writes.
An early version's predicate was `recorded === command || recorded.includes("gitea-axi")`, which reintroduced the very coupling this task removes and could have deleted an unrelated tool's hook whose command merely mentioned `gitea-axi`.
It is now exact-equality only.
That is sufficient: a *re-run* records an identical command, and the upgrade case is handled by the bare name being stable in the first place.
Duplicates are pruned only from `~/.claude/settings.json` and `~/.codex/hooks.json`.
The third integration, OpenCode, is a plugin file the SDK rewrites wholesale behind its own managed marker, so it cannot accumulate duplicates.
### Verification
Criterion 6 was met by driving the built Nix binary rather than by a test, since no test tier installs a wrapper.
Against `result/bin/gitea-axi`: on `PATH` records `gitea-axi`; off `PATH` records the store entrypoint path; a same-named impostor on `PATH` falls back rather than recording the name; and re-running in both the on-`PATH` and fallback cases leaves exactly one entry.
A globally `npm install`-ed pack of the same tree records `gitea-axi` through its symlinked `bin`, confirming the npm shape still resolves.
Criterion 5 is what `nix build` now demonstrates: with `postUnpack` deleted the fast tier runs from `/build/source`, a path with no marker in it, and the re-run idempotency test passes there — which it could not before the pruning change.