docs: record that the session-start hook stores an absolute path (task 0042) #51

Merged
alexion merged 2 commits from task-0042-verify-hook-path-resolution into main 2026-07-20 12:43:35 -04:00
9 changed files with 225 additions and 15 deletions

View File

@@ -156,16 +156,37 @@ _Avoid_: mock mode, stub mode
### Distribution
**Agent Skill**: The markdown file bundled inside the npm package and installed to `~/.claude/skills/` by the `setup` command.
**Agent Skill**: The markdown file bundled inside the npm package and installed to `~/.claude/skills/` by the `setup` command, or declared from the package by the [[home-manager module]].
_Avoid_: skill file, Claude skill
**setup**: The explicit subcommand that installs the Agent Skill into `~/.claude/skills/`; gitea-axi's primary fulfillment of AXI Principle 7 (Ambient context).
Idempotent: re-running reports already-installed/updated rather than failing.
There is no postinstall script — installation of the skill is always an explicit user action.
`setup hooks` additionally opts into the [[SessionStart hook]].
It is the [[imperative install path]], and works only where the operator owns the target files; against a read-only target it reports the condition rather than writing.
_Avoid_: postinstall, installer script
**SessionStart hook**: An opt-in ambient-context mechanism installed by `setup hooks` via `axi-sdk-js`'s `installSessionStartHooks()` (Claude Code `settings.json`, Codex `hooks.json`, OpenCode plugin).
**SessionStart hook**: An opt-in ambient-context mechanism installed by `setup hooks` via `axi-sdk-js`'s `installSessionStartHooks()` (Claude Code `settings.json`, Codex `hooks.json`, OpenCode plugin), or declared by the [[home-manager module]].
It runs the bare `gitea-axi` binary (the short [[dashboard]] tier) in the session's working directory at session start and injects the output into the agent's context.
The SDK's installer registers the binary with no arguments, so the hook always runs the short tier; outside a Gitea repo it produces the dashboard's `REPO_NOT_FOUND` error, an accepted noise trade-off.
The recorded command is currently the entrypoint's absolute path on any wrapper-based install, which rots whenever that path moves; recording the bare binary name and resolving it through `PATH` is agreed and lands with task 0043.
_Avoid_: session hook, ambient hook, postinstall hook
**imperative install path**: Installation of the Agent Skill and the [[SessionStart hook]] by running `setup`, which writes into the operator's agent configuration directory.
Requires the operator to own those files; a declaratively generated configuration renders them read-only and the command reports rather than writes.
Contrast the [[declarative install path]]. Both are supported and neither supersedes the other.
_Avoid_: manual install, imperative setup
**declarative install path**: Installation of the Agent Skill and the [[SessionStart hook]] by declaring them in a Nix configuration, which generates the agent configuration rather than mutating it.
Agreed and specified; the outputs it consumes land with task 0045.
Consumes the package's exposed Skill location and [[hook specification]], either directly or through the [[home-manager module]].
Chosen where the operator's agent configuration is generated and therefore read-only; contrast the [[imperative install path]].
_Avoid_: nix install, declarative setup
**home-manager module**: The flake output that declares the Agent Skill and the [[SessionStart hook]] from the package, as the [[declarative install path]]'s ergonomic front end (task 0045).
Importing it does nothing until enabled; it installs the package by default, with a null package the documented way to declare configuration without installing the binary, and carries a toggle per managed piece.
_Avoid_: nix module, HM module
**hook specification**: The single committed declaration of the [[SessionStart hook]]'s recorded shape — command, timeout, and matcher (task 0045).
Read by both the Nix expression and the test suite, so that the [[declarative install path]] and the [[imperative install path]] cannot disagree about what the hook is without failing a test.
_Avoid_: hook config, hook schema

View File

@@ -200,15 +200,30 @@ The workflow keeps its container-and-npm shape, deliberately preserving the GitH
## Further Notes
### Open verification item
### Resolved verification item: the hook records the absolute path under Nix
The `setup` command's hook installation passes the SDK both an absolute path to the running entrypoint and the bare binary name.
Under Nix the absolute path is content-addressed: it changes on every rebuild and is eventually garbage-collected, so a hook recording it would break silently, since a session-start hook that cannot execute simply does not run.
The bare binary name strongly suggests the SDK prefers search-path resolution and treats the absolute path as a fallback, which would make this a non-issue, but this could not be confirmed during design because the dependency was not installed.
The design-time hope was that the SDK prefers search-path resolution and treats the absolute path as a fallback, which would have made this a non-issue.
It was verified against the installed dependency and against a real Nix build, and the answer is the unfavourable one: **under Nix the absolute store path is recorded**, even with the binary on `PATH`.
The decision is to verify before acting.
If the SDK does record the absolute path, the immediate mitigation is documenting that the hook setup should be re-run after an upgrade.
Changing the `setup` command to prefer the bare name would then become a separate task with its own ADR — justified on the grounds that a stable search-path name is more robust for *every* installation method, and explicitly not as a special case that detects Nix store paths in application code.
The SDK's `resolvePortableHookCommand` returns the bare name only when some `PATH` entry *realpath-matches* the entrypoint, and the absolute path otherwise.
That test is what splits the two installation methods, and the split is a property of how each one puts the binary on `PATH`:
- **npm** symlinks the `bin` entry directly at the entrypoint, so the realpath comparison succeeds and the bare name is recorded.
- **Nix** installs the `bin` entry as a *generated wrapper script* that invokes `node <path>``nodejsInstallExecutables` inside `npmInstallHook`, plus this package's own `makeWrapper` layer for `git` and `tea`.
A wrapper's realpath is the wrapper, never the entrypoint, so the comparison cannot succeed and the absolute path is recorded.
So the preference for the bare name is real, but it is unreachable through any wrapper-based install.
It is not that Nix was overlooked; it is that the mechanism keys on a filesystem relationship only the symlink shape has.
The mitigation is therefore documentation, per the decision recorded when this item was opened: the `setup` command's help text states that `setup hooks` must be re-run after an upgrade.
The failure it guards against is silent — a session-start hook that cannot execute simply does not run, so a user gets no error, only the quiet absence of their ambient dashboard.
Changing the `setup` command to prefer the bare name remains a separate task with its own ADR, justified on the grounds that a stable search-path name is more robust for *every* installation method, and explicitly not as a special case that detects Nix store paths in application code.
Two findings feed that future task.
First, the mitigation above is documentation against a silent failure, which is the weakest kind of fix.
Second, a related defect shares the same line: `isManagedHook` recognises its own hook by testing whether the recorded command string *contains* the marker `gitea-axi`, so an entrypoint path lacking that substring makes `setup hooks` append a duplicate rather than update in place, contradicting the idempotency its help text promises.
That coupling is why `package.nix` renames its build tree in `postUnpack`; the rename can be deleted once the hook no longer depends on the entrypoint path.
### Verified during design

View File

@@ -19,10 +19,10 @@ Changing the `setup` command to prefer the bare name is explicitly **not** part
## Acceptance criteria
- [ ] The SDK's actual hook-path behavior is determined by observation against the installed dependency, not inference from its interface.
- [ ] The finding is recorded where a future reader will meet it, so the question is not re-opened from scratch.
- [ ] If the absolute path is recorded, the documentation states that hook setup must be re-run after an upgrade.
- [ ] No change is made to how the `setup` command constructs the hook in this task.
- [x] The SDK's actual hook-path behavior is determined by observation against the installed dependency, not inference from its interface.
- [x] The finding is recorded where a future reader will meet it, so the question is not re-opened from scratch.
- [x] If the absolute path is recorded, the documentation states that hook setup must be re-run after an upgrade.
- [x] No change is made to how the `setup` command constructs the hook in this task.
## Evidence gathered during task 0037
@@ -47,3 +47,52 @@ Consequences for this task:
- Both defects trace to the same resolution line, so they should be weighed together.
- The stale store path is user-facing breakage on the install method task 0037 added, which argues for not letting this drift far behind it.
- `package.nix` carries a `postUnpack` rename of the build tree purely to work around the substring coupling. It is commented as a workaround and should be **deleted as part of this task**, once the hook no longer depends on the entrypoint path.
## Implementation Notes
### The observation
Task 0037's evidence was re-verified independently rather than taken on trust, since acceptance criterion 1 asks for observation and not for citation.
Two observations were made against the installed dependency.
A probe drove `resolvePortableHookCommand` directly with two synthetic install trees.
Given a `PATH` entry that is a *symlink* to the entrypoint it returned the bare name `gitea-axi`; given a `PATH` entry that is a *wrapper script* invoking `node <entrypoint>` it returned the absolute path.
Then the flake was built and the resulting binary driven for real against a temporary `HOME`.
With `$out/bin` on `PATH`, `~/.claude/settings.json` recorded:
```
/nix/store/pqxhyy5cg1rljyn78kxfpxyfpgz7rgzk-gitea-axi-0.1.0/lib/node_modules/gitea-axi/dist/main.js
```
This confirms 0037's finding and sharpens it.
The task framed the bare name as a hint that the SDK "prefers search-path resolution"; that preference is real, but it is gated on a `PATH` entry whose realpath equals the entrypoint.
npm satisfies that by symlinking its `bin` entry; Nix cannot, because `nodejsInstallExecutables` generates a wrapper script and this package adds a second `makeWrapper` layer for `git` and `tea`.
So the behaviour is not Nix-specific — it applies to *any* wrapper-based install — which strengthens the case, already recorded, that the eventual fix belongs in the `setup` command for every installation method rather than as a Nix special case.
### Deviations
**The `postUnpack` rename in `package.nix` was kept, not deleted.**
The "Evidence gathered during task 0037" section above says it "should be **deleted as part of this task**", which conflicts with acceptance criterion 4 and with the "What to build" section's statement that changing the hook to prefer the bare name is "explicitly **not** part of this task".
The conflict resolves on the Evidence section's own wording: the deletion is conditioned on "once the hook no longer depends on the entrypoint path", and establishing that precondition is exactly the out-of-scope change.
Deleting the rename now would leave the derivation's build tree at a path with no `gitea-axi` segment, which `isManagedHook`'s substring test still requires, and `test/setup.test.ts` would fail inside `checkPhase`.
The rename's comment was rewritten instead: it previously promised that task 0042 would remove the coupling, which would have become a stale forward reference the moment this task landed.
**The documentation surface is the `setup` help text.**
The repository has no README, so the command's own help is the only place a user meets this.
Criterion 4 is untouched — `binaryNames`, `execPath`, and the `installSessionStartHooks` call are all unchanged; only the `usage` string moved.
**One sentence beyond the strict ask.**
The help text asserted "Both are idempotent" unqualified, which the finding recorded in this same commit makes false for the duplicate-append case.
Leaving a statement the commit itself documents as untrue seemed worse than a one-line caveat, so the new paragraph notes that a stale entry may survive a re-run.
### Follow-up
The successor is task 0043, which covers both defects together — the absolute-path recording and `isManagedHook` recognising its hook by substring — since both trace to the same resolution line, and deletes `package.nix`'s `postUnpack` rename with them.
Grilling the mitigation afterwards found the framing here too narrow.
The maintainer's agent configuration is generated declaratively, so `~/.claude/settings.json` and the installed Skill are both read-only symlinks into the Nix store: `setup hooks` cannot write at all, and `setup` crashes outright on an unhandled filesystem error.
The recorded path's *shape* is therefore not the whole defect — the deeper one is that `setup` is write-only against a target that some operators cannot let it write.
Tasks 0044 and 0045 follow from that: a clean failure on unwritable targets, and a declarative install path that generates the configuration instead of mutating it.
The help-text mitigation added by this task is deliberately left in place rather than pre-emptively reverted.
It is accurate until task 0043 lands, which removes it as an acceptance criterion.

View File

@@ -0,0 +1,34 @@
---
spec: nix-flake-packaging
---
## What to build
Make the SessionStart hook survive an upgrade by recording a name that does not move.
Task 0042 established by observation that the hook records the entrypoint's absolute path on every wrapper-based install, and documented a re-run-after-upgrade mitigation.
This task removes the need for that mitigation.
The SDK returns the bare binary name only when a `PATH` entry realpath-matches the entrypoint it is handed.
An npm install satisfies that by symlinking its `bin` entry straight at the entrypoint; a wrapper-based install cannot, because a script that *invokes* a file never resolves *to* that file.
Handing the SDK the location where the binary actually resolves on `PATH`, rather than the module-relative entrypoint, makes the match succeed and the bare name get recorded — using the SDK's own resolution rather than bypassing it.
When the binary is not on `PATH` there is nothing to hand it, and the existing absolute-path behaviour stands unchanged as the fallback.
This is not a Nix accommodation.
Any wrapper-based install has the same shape — a shim, a launcher, a generated `.cmd` — and the fix is the convention for tools that write into user-owned configuration: prior art records a bare name and lets `PATH` resolve it, reserving absolute paths for configuration that a package manager regenerates.
A second defect shares the same line and is fixed here.
The hook is recognised as its own by testing whether the recorded command string *contains* the marker, so an entrypoint path lacking that substring makes re-running `setup hooks` append a duplicate rather than update in place, contradicting the idempotency its help text promises.
Recording the bare name makes the marker match by construction, but the recognition itself should not depend on the recorded command's shape.
The Nix derivation renames its build tree solely to work around that substring coupling.
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.

View File

@@ -0,0 +1,29 @@
---
spec: nix-flake-packaging
---
## What to build
Report an unwritable target as an error the user can act on, instead of crashing.
Both halves of `setup` assume the files they manage are writable.
When they are not — because a configuration manager owns them, because a file is flagged immutable, because the path is root-owned — the skill install raises a raw filesystem error with no handling at all, and the hook install surfaces the underlying message through its error collector without saying what a reader should do about it.
Neither failure is exotic.
Any tool that manages a user's agent configuration declaratively renders these paths read-only, and gitea-axi's own Nix install method encourages exactly that arrangement.
The error names the file and the condition, and points at the general remedy: the file appears to be managed elsewhere, so the skill or hook should be declared through that configuration rather than installed by this command.
It deliberately does not guess at the cause.
Read-only is not diagnostic of any particular manager, and naming one would be wrong for most users who hit this.
The failure follows the CLI's existing error convention rather than inventing a shape, so it carries a code and help lines like every other error the tool reports.
## Acceptance criteria
- [ ] An unwritable skill target produces a structured CLI error rather than an unhandled filesystem exception.
- [ ] An unwritable hook target produces the same class of error, with the same guidance.
- [ ] Both errors name the file that could not be written and state that it appears to be managed by another tool.
- [ ] Neither error names or infers a specific configuration manager.
- [ ] A skill target that is unwritable but already byte-identical to the bundled copy succeeds rather than failing, since nothing needs to be written.
- [ ] The errors carry a code and help lines consistent with the rest of the CLI's error surface.

View File

@@ -0,0 +1,45 @@
---
spec: nix-flake-packaging
blocked-by: 0043-hook-records-bare-binary-name
---
## What to build
Let a Nix configuration declare gitea-axi's ambient context, instead of running a command that writes it.
`setup` and `setup hooks` are write-only.
They install the Agent Skill and the SessionStart hook by writing into the user's agent configuration directory, which works only when the user owns those files imperatively.
An operator whose agent configuration is generated declaratively cannot use either: the targets are read-only, and the operator is left hand-copying the Skill into their own configuration, where it silently drifts from the package that ships it.
The spec currently lists a home-manager module under Out of Scope, deferring it until there was usage evidence that the trade-off was worth making.
That evidence now exists, and the deferral's stated reasoning does not survive it: the concern was the automatism that ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script, and a module the operator explicitly imports and enables is the opposite of an implicit install.
Revising that Out of Scope entry, and recording the decision as an ADR, is part of this task.
Two layers, the second built on the first.
The package gains a stable, documented location for the bundled Agent Skill, and exposes both the Skill and the hook's specification as attributes a Nix expression can consume.
Today the Skill's only address is a path inside the installed node modules tree, which is an implementation detail no consumer should depend on.
On top of that, the flake exposes a home-manager module: a thin wiring layer that declares the Skill and the hook from those attributes.
It follows the conventions the home-manager module tree overwhelmingly uses — an enable option so that importing the module does nothing until it is switched on, an overridable package option, and installation of that package by default with a null value as the documented opt-out for an operator who supplies the binary another way.
Each managed piece has its own toggle, defaulting on, so an operator can take the Skill declaratively while continuing to write the hook by hand.
The hook's specification is declared once, in a committed file that both the Nix expression and the test suite read.
Declaring it in the Nix expression alone would create a second source of truth alongside the behaviour of the imperative install path, with nothing to keep them agreed; a test that hardcoded the same values a third time would verify nothing.
The test drives the imperative install against a temporary home directory and asserts that what it writes matches what the file declares, so a divergence — including one introduced by the SDK changing the envelope it writes — fails a test rather than passing silently into a release.
The two installation paths remain independent and both supported: the command for operators who own their configuration, the module for operators whose configuration owns them.
## Acceptance criteria
- [ ] The bundled Agent Skill is installed to a stable location in the package output that is not an internal implementation path.
- [ ] The package exposes the Skill and the hook specification as attributes consumable from a Nix expression without building or running anything.
- [ ] The hook specification is declared in a single committed file, read by both the Nix expression and the test suite.
- [ ] A test drives the imperative hook install and asserts that what it writes matches the declared specification, failing if either side drifts.
- [ ] The flake exposes a home-manager module that declares the Skill and the hook.
- [ ] Importing the module without enabling it changes nothing about the resulting configuration.
- [ ] The module installs the package by default, and accepts a null package as the documented way to declare the configuration without installing the binary.
- [ ] The Skill and the hook each have their own toggle, both defaulting to on.
- [ ] The module composes with an existing configuration that already declares its own SessionStart hooks and skills, rather than conflicting with it.
- [ ] The spec's Out of Scope entry excluding a home-manager module is revised, and the decision to reverse it is recorded as an ADR.
- [ ] The user-facing documentation describes both installation paths and when each applies.

View File

@@ -71,3 +71,9 @@ Its `act` fork declares `RawContinueOnError` on the `Step` struct only — `pkg/
Gitea's own syntax-comparison page does not list the gap.
Put `continue-on-error` on each step instead: `act` and GitHub Actions both honour it there, and a job whose every step carries it concludes green on either platform.
The same fork historically ignored `jobs.<id>.if` (go-gitea#25897), so treat any job-level key as needing a check against the fork's structs rather than against GitHub's documentation.
The session-start hook installed by `setup hooks` records the entrypoint's **absolute path**, not the bare binary name, on every wrapper-based install.
`resolvePortableHookCommand` in `axi-sdk-js` returns the bare name only when a `PATH` entry *realpath-matches* the entrypoint.
npm symlinks its `bin` entry straight at `dist/main.js`, so that match succeeds; Nix installs a generated wrapper script that invokes `node <path>`, whose realpath is the wrapper, so the match cannot succeed and the store path is recorded.
Passing `binaryNames` therefore does not make the hook portable under Nix — verify by driving the installed binary and reading `~/.claude/settings.json`, not by reading the SDK's interface.
The consequence is silent: a hook whose recorded path no longer exists does not run and does not warn.

View File

@@ -68,9 +68,12 @@ buildNpmPackage {
# finding "gitea-axi" within it. Naming the tree makes the build representative
# rather than an environment no operator ever has.
#
# This coupling is a defect, not a property worth preserving — see task 0042,
# which removes the hook's dependence on the entrypoint path entirely. Once it
# lands this rename should go with it.
# This coupling is a defect, not a property worth preserving. Task 0042
# verified the resolution behaviour and documented the mitigation, but left
# the hook's dependence on the entrypoint path in place: removing it needs its
# own ADR, since a stable search-path name is the right answer for every
# installation method and not a Nix special case. This rename goes away with
# that task, not before.
postUnpack = ''
mv "$sourceRoot" gitea-axi
export sourceRoot=gitea-axi

View File

@@ -18,6 +18,14 @@ Install gitea-axi's ambient context for agent sessions.
Both are idempotent: re-running updates the managed files in place rather than
failing. There is no postinstall script — installation is always explicit.
Re-run "setup hooks" after upgrading gitea-axi. The hook records an absolute
path to the entrypoint, which moves when the install location changes, and a
session-start hook that cannot be executed fails silently rather than warning.
This matters most for immutable installs such as Nix, where every rebuild lands
the entrypoint at a fresh path and the old one is eventually collected. When the
path moves, the re-run may leave the stale entry behind instead of replacing it;
remove it by hand if a duplicate appears.
flags:
--help Show this help
`;