Compare commits

..

36 Commits

Author SHA1 Message Date
627fc9a32b feat(nix): expose skill for project delivery
All checks were successful
CI / test (22) (pull_request) Successful in 54s
CI / test (true, 24) (pull_request) Successful in 1m5s
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 3s
2026-07-29 11:54:41 -04:00
1468003f5b feat(nix): check the home-manager module's composition (task 0047)
All checks were successful
CI / test (22) (pull_request) Successful in 49s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 2s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m3s
CI / flake (push) Successful in 3s
Add a `home-manager` flake input, its nixpkgs following this flake's, and a
`home-manager-module` check that evaluates the real module through
home-manager's standalone entry point and builds the home files derivation
under four operator configurations, asserting on the tree each produces:
the Skill coexisting with an operator's own skills in both the attribute-set
and whole-directory forms, the sibling-enable gate leaving no Skill when
Claude Code is off, and the hook merging into an operator's own SessionStart
list. This is the first automated proof of the module's composition (ADR 0021),
replacing verification by maintainer rebuild.
2026-07-20 20:10:15 -04:00
534097121d feat(nix): install the CLI unconditionally, gate context per harness (task 0046)
All checks were successful
CI / test (22) (pull_request) Successful in 52s
CI / test (true, 24) (pull_request) Successful in 1m4s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 48s
CI / test (true, 24) (push) Successful in 1m2s
CI / flake (push) Successful in 3s
Reshape the home-manager module so `programs.gitea-axi.enable` installs the
binary always, and the Claude Code context follows the harness. The two
per-artefact toggles and their assertion are replaced by one per-harness
toggle, `enableClaudeCodeIntegration` (default true); its artefacts land only
when `programs.claude-code.enable` is also on, silently absent otherwise.

The Agent Skill is now written through home.file into Claude Code's skills
directory, rather than contributed to `programs.claude-code.skills`, so it
composes with both the attribute-set and whole-directory forms of an operator's
own skills option. The Skill write is gated on `claude-code.enable` explicitly
(keeping package realisation lazy); the hook keeps its sibling-module gate for
free, and the asymmetry is commented. Supersedes three decisions of ADR 0020;
recorded in ADR 0021.

INSTALL.md is updated to the new option surface and the path-form limitation
paragraph removed. Adds the parent spec and the follow-up task 0047 (the flake
check proving composition, implemented separately).
2026-07-20 19:50:44 -04:00
e77a1f5e22 feat(nix): expose declarative outputs and a home-manager module (task 0045)
All checks were successful
CI / test (22) (pull_request) Successful in 49s
CI / test (true, 24) (pull_request) Successful in 1m5s
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 3s
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
against files the operator is assumed to own, so an operator whose agent
configuration is generated cannot use them at all.

The package installs the bundled Agent Skill to share/gitea-axi/skills and
publishes it as `passthru.skill`, alongside `passthru.sessionStartHook` read
from `session-start-hook.json` — a committed declaration the fast tier reads
too, so a test drives `setup hooks` and asserts the two agree.

On top of that, `homeModules.gitea-axi` declares both from those attributes
through home-manager's own Claude Code options, so an operator's existing
skills and SessionStart hooks compose rather than collide. Importing it
without enabling it yields a byte-identical generation.

The spec's Out of Scope entry deferring a home-manager module is deleted;
ADR 0020 records the reversal, and INSTALL.md describes both paths.
2026-07-20 13:51:19 -04:00
a1e68dc530 feat(setup): report an unwritable target as a structured error (task 0044)
All checks were successful
CI / test (22) (pull_request) Successful in 50s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 47s
CI / test (true, 24) (push) Successful in 1m4s
CI / flake (push) Successful in 3s
Both halves of `setup` assumed the files they manage are writable. A
declaratively managed target — read-only because a configuration manager
owns it, because a file is flagged immutable, or because the path is
root-owned — made the skill install raise a raw filesystem exception and
the hook install surface the underlying message with no guidance.

Both now fail with `TARGET_NOT_WRITABLE`, naming the file and pointing at
the general remedy: it appears to be managed by another tool, so declare
the skill or hook through that configuration instead. The error names no
particular manager, because read-only is not diagnostic of one.

A target already byte-identical to the bundled copy still succeeds —
nothing needs writing, so its being read-only is beside the point.
2026-07-20 13:25:03 -04:00
27aad04984 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.
2026-07-20 13:12:40 -04:00
4e92dde4e4 docs: break the declarative install path into tasks (tasks 0043-0045)
All checks were successful
CI / test (22) (pull_request) Successful in 48s
CI / test (true, 24) (pull_request) Successful in 1m4s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 55s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 2s
Grilling task 0042's mitigation found its framing too narrow. Recording an
absolute entrypoint path is one defect; the deeper one is that `setup` is
write-only, and an operator whose agent configuration is generated
declaratively cannot let it write at all. On such a machine both halves fail —
`setup hooks` against a read-only settings file, and `setup` on an unhandled
filesystem error — and the Agent Skill gets hand-copied into the operator's own
configuration, where it silently drifts from the package that ships it.

Three tasks follow:

- 0043 records the bare binary name, resolved through PATH, so the hook
  survives an upgrade on any wrapper-based install. Fixes the marker-substring
  coupling with it and drops the derivation's build-tree rename.
- 0044 reports an unwritable target as a structured error naming no cause,
  rather than crashing.
- 0045 adds the declarative install path: a stable Skill location, the Skill
  and hook specification exposed as Nix-consumable attributes, one committed
  hook specification read by both the expression and the test suite, and a
  home-manager module wiring them. Blocked by 0043, whose bare name the
  specification declares.

CONTEXT.md gains the four terms this settled and amends `setup` and
`SessionStart hook`, which described the imperative path as the only one.
Entries for unbuilt work name the task that lands them, so the glossary does
not assert behaviour the code lacks.

The re-run-after-upgrade help text this branch added stays as it is: accurate
until 0043 removes it, which that task carries as a criterion.
2026-07-20 12:29:42 -04:00
397da5d7a6 docs: record that the session-start hook stores an absolute path (task 0042)
All checks were successful
CI / test (22) (pull_request) Successful in 46s
CI / test (true, 24) (pull_request) Successful in 1m6s
CI / flake (pull_request) Successful in 3s
Resolve the nix-flake-packaging spec's open verification item by observation
rather than inference, and act on the unfavourable answer.

`resolvePortableHookCommand` in axi-sdk-js returns the bare binary name only
when a PATH entry realpath-matches the entrypoint. npm symlinks its bin entry
straight at dist/main.js and satisfies that; Nix installs a generated wrapper
script whose realpath is the wrapper, so the absolute store path is recorded
instead. Verified by probing the SDK with both install shapes and by driving
the flake-built binary against a temporary HOME.

The path is content-addressed, so it moves on every rebuild and is eventually
collected, and a session-start hook that cannot execute fails silently. The
mitigation is documentation, per the decision recorded when the item was
opened: the setup help text now says to re-run `setup hooks` after an upgrade.

How the setup command constructs the hook is deliberately unchanged. Preferring
the bare name is the right answer for every wrapper-based install, not a Nix
special case, so it belongs in a successor task with its own ADR alongside the
related `isManagedHook` substring defect. package.nix's postUnpack rename
therefore stays; its comment no longer promises this task will remove it.
2026-07-20 10:03:45 -04:00
710bbfdeac ci: build the flake in a non-gating job (task 0041)
All checks were successful
CI / test (22) (pull_request) Successful in 48s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m8s
CI / flake (push) Successful in 3s
A distinct `flake` job runs `nix flake check` on push and pull request,
catching flake rot — most concretely a build-relevant file left out of
package.nix's source allowlist — at the commit that causes it rather than
weeks later at the maintainer's next system rebuild.

The job is non-gating by two mechanisms: no `needs` edge, so it neither
waits on the test job nor is waited on, and `continue-on-error` so a red
build does not fail the run. That flag sits on the steps rather than the
job because Gitea's `act` fork declares it on its Step struct only and
silently ignores the job-level key; recorded as a Gotcha.
2026-07-20 07:35:45 -04:00
ccc8dbe998 ci: matrix over Node 22 and 24, add the bench and packaging tiers (task 0040)
All checks were successful
CI / test (22) (pull_request) Successful in 1m8s
CI / test (true, 24) (pull_request) Successful in 1m12s
CI / test (22) (push) Successful in 47s
CI / test (true, 24) (push) Successful in 1m4s
The workflow pinned Node 20, which reached end-of-life in April 2026, while
the manifest promised support down to it — so the entire claimed range below
the single tested version went unverified and its floor was unsupported.

Matrix over the two supported long-term-support majors and narrow the engine
range to `^22 || ^24`, naming exactly what is tested. Narrowing is free now
because nothing has been published and no tags exist.

The benchmark harness tier joins every leg: it is deterministic and needs no
network, and its non-default runner configuration made it easy to believe it
was running when it was not. The end-to-end and packaging tiers run on the
highest leg only, conditioned on a flag attached to that leg through a matrix
`include` entry rather than on a version number restated at each site. The
benchmark smoke tier stays out, since it would pass by skipping.

`@types/node` follows the new floor; it was the last Node 20 reference in the
manifest, and the typecheck runs on every leg.
2026-07-19 23:45:55 -04:00
6a9d4d4770 feat(nix): add a dev shell and a checks output (task 0039)
All checks were successful
CI / test (pull_request) Successful in 54s
CI / test (push) Successful in 53s
`nix develop` now yields the toolchain the repository actually needs — Node,
`git`, `tea`, and `curl` — giving a declarative answer to "what do I need to
work on this", which the repository previously specified nowhere.

The shell takes its Node from the package's `passthru` rather than naming
`pkgs.nodejs` a second time, so development and the shipped artifact cannot
drift onto different majors and cannot be set independently. `package.nix`
declares that `passthru` as an interface rather than leaving the shell to read
an incidental build attribute; it does not enter the derivation, so the store
path is unchanged.

The checks output aliases the package, so `nix flake check` builds it and
thereby runs both its verification phases instead of being a silent no-op. No
per-stage checks: the only stage adding coverage is the full typecheck, which
spans `test/` and `bench/` and would drag the benchmark harness into the
derivation's inputs, undoing the source filtering. It stays in CI.

The shell deliberately omits `gitea-axi` itself. The benchmark's arm resolves
that binary by name off PATH and must get the locally built `dist/main.js`, so
supplying the packaged one would silently substitute the wrong artifact.
2026-07-19 23:36:40 -04:00
82cdad4ce0 test(nix): drive the installed binary through the shared tier (task 0038)
All checks were successful
CI / test (pull_request) Successful in 53s
CI / test (push) Successful in 54s
The Nix build now runs the installed-binary facet of the packaging tier
against the binary it has just produced, via an `installCheckPhase` that
sets `GITEA_AXI_INSTALLED_BIN` to `$out/bin/gitea-axi` and runs the new
`test:installed` script. Running after `fixupPhase` means the binary
under test is the wrapped one an operator actually gets, and naming it is
all the phase does — the assertions stay in the shared tier, so the npm
and Nix installation paths cannot drift apart in what they guarantee.

`npmInstallHook` prunes dev dependencies out of the build tree before the
check runs, so `preInstall` snapshots `node_modules` with `cp -al` and the
check restores it by copying, leaving the snapshot intact for a replayed
phase.

Two defects fixed in passing, both surfaced by review:

`test:installed` pins a test file by path while the packaging runner sets
`passWithNoTests: true`, so moving that file would have taken the build
green having asserted nothing — the same silently-inert trap `doCheck`
sprang in task 0037. The script now passes `--passWithNoTests=false`.

`checkPhase`'s vitest left a timestamped run cache under
`node_modules/.vite`, which `npmInstallHook` copied into `$out`, shipping
a stray cache and making the derivation non-reproducible. It is now
removed before the install phase; `nix build --rebuild` passes.
2026-07-19 23:23:01 -04:00
5a2874d11c feat(nix): package gitea-axi as a flake with a PATH-suffixing wrapper (task 0037)
All checks were successful
CI / test (pull_request) Successful in 54s
CI / test (push) Successful in 52s
Add a flake at the repository root exposing gitea-axi as a package, with the
derivation in its own callable expression so it stays buildable outside a flake
context and usable in an overlay unchanged.

Dependencies are fetched from the lockfile's integrity fields via importNpmLock
rather than a committed fixed-output hash, and the version is read from the
package manifest, so neither a dependency bump nor a release edits any Nix
expression. The source is an explicit allowlist, keeping ADR, spec, task and
bench churn out of the derivation's inputs.

The installed binary is wrapped with --suffix PATH per ADR 0018: the operator's
own git and tea win, and the closure's are a fresh-machine fallback.

Two things differ from the plan. Systems coverage is three targets, not four:
nixpkgs 26.11 dropped x86_64-darwin and now throws on evaluating it, which
would break nix flake show and nix flake check for every system at once. And
buildNpmPackage supplies no check hook, so doCheck alone was silently inert and
produced a green build whose tests never ran; running the fast tier needs an
explicit checkPhase.
2026-07-19 23:00:48 -04:00
488058630b docs: correct the tea login store and record the nix toolchain gotcha
The store holds only `alexion` now; `csv-reviewer` is gone for good.

Also records that node/npm/tea are absent from a non-interactive shell on
this host and that git carries no configured identity, both of which cost
a full session of rediscovery.
2026-07-19 22:40:58 -04:00
6c31eb9e62 test: split the packaging tier and parameterize its installed binary (task 0036)
All checks were successful
CI / test (pull_request) Successful in 53s
CI / test (push) Successful in 52s
The packaging tier held two kinds of assertion joined only by an
expensive shared setup: the shape of the packed tarball and its
manifest, and the behaviour of the resulting installed binary. Split
them, and teach the second to take the binary it drives from
GITEA_AXI_INSTALLED_BIN.

When that variable names an existing binary the tier drives it and skips
pack-and-install entirely; unset, it packs and installs exactly as
before. The installed-binary facet becomes one seam with two callers —
the npm distribution path today, the Nix installation path in task 0038
— so the two cannot drift apart in what they guarantee about an
installed gitea-axi. Nothing in it may assert on how the binary came to
exist, since store paths, wrapper internals, and the arrangement of the
installed tree are implementation detail of the installation method.

The tarball assertions stay npm-only: no other distribution method
produces a tarball or a packed manifest.
2026-07-19 22:28:25 -04:00
0cbfe43ae0 docs: plan Nix flake packaging (spec, ADR 0018, tasks 0036-0042)
All checks were successful
CI / test (push) Successful in 55s
Add the design record for distributing gitea-axi as a Nix flake: a
package, a development shell, and a checks output, plus the continuous
integration changes that come with it.

ADR 0018 records the wrapper's deference to the operator's own `git` and
`tea` — the reverse of the hermetic instinct, chosen because `tea`
refreshes OAuth tokens in place and so must not have two versions
mutating one credential store.

Also records the `tea`-is-still-a-runtime-dependency gotcha, which ADR
0002's title obscures.
2026-07-19 22:22:02 -04:00
408956cf32 docs: rewrite bench results for the post-fix 4-arm snapshot
All checks were successful
CI / test (pull_request) Successful in 55s
CI / test (push) Successful in 52s
Refresh the benchmark README from a clean co-temporal 4-arm run taken after
the find-then-act skill rewrite, the /api/v1 tolerance, and the search
next-step fix. gitea-axi drops from most-expensive arm to co-leader: within
~1% of raw REST overall, cheapest structured interface by ~23%, cheapest
arm outright on the read and find-then-act tiers, at 100% success and the
fewest turns of any arm.
2026-07-19 12:31:05 -04:00
5422f49f38 feat(search): guide a 0-result search and fill the single-match number
The next-step suggestion on `search issues`/`search prs` is now conditioned
on the in-repo match count. On a miss it pointed the agent at `view <number>`,
which is nonsensical when nothing matched; it now suggests the non-indexed
`issue list --state all` / `pr list --state all` fallback, which recovers from
both an over-narrow query and issue-indexer lag without naming the cause. On
exactly one match it fills the real number (`issue view 2`), applying AXI
Principle 9's single-id fill. Two or more matches keep the parameterized
placeholder.

Search stays a locator — it never auto-loads the detail even on a single
match; ADR 0017 records that decision (a deliberate narrowing of Principle 4)
and the CONTEXT.md search term is updated to match.
2026-07-19 10:17:14 -04:00
0b0186674e feat(bench): add --skill to override the gitea-axi arm's bundled skill
bench:run gains a --skill <path> flag, threaded into the existing
BuildArmOptions.skillPath, so a skill variant can be A/B'd against the
shipped SKILL.md with the same binary and harness — the mechanism used to
validate the find-then-act rewrite — without mutating the shipped file.
2026-07-19 09:46:27 -04:00
d56b1d0707 refactor(skill): steer find-then-act instead of open-ended discovery
The bundled skill's Discovery section told the agent to run the bare
dashboard and reach for --help proactively, and advertised overlapping
find-paths — inducing exploratory commands that made the gitea-axi arm the
most expensive of the benchmark's four. Replace it with a "find the target,
then act" section, name the non-obvious mutation flags so common edits do
not need --help, and drop the setup line and the over-tea/raw/git bullets
that only duplicated the description. A same-time A/B cut cost-equivalent
tokens ~10% and collapsed bare-dashboard use from 60% to 7% with no loss of
success.
2026-07-19 09:41:13 -04:00
dd58cd9dad fix(context): tolerate a trailing /api/v1 in the base URL
The client (gitea-js) appends /api/v1 to the base URL itself, so a
GITEA_AXI_API_URL that already carries it — a natural guess given the
variable's name — doubled the segment and failed as a spurious
REPO_NOT_FOUND. Normalize the base URL by stripping a trailing /api/v1
(and any trailing slashes) on both the env-URL and tea-login paths, so the
host base and the /api/v1 endpoint both resolve.
2026-07-19 09:41:13 -04:00
f9125e099d docs: correct bench/CLI login name and record bench test-config gotcha
The tea login store here holds `alexion` and `csv-reviewer`, not `axi`;
`selectLogin` matches `--login` by exact name, so `--login alexion` works and an
unknown `--login axi` fails with VALIDATION_ERROR — the prior note had this
backwards. Also record that bench/ unit tests run only under
vitest.bench.config.ts (plain `vitest run` matches test/** and finds none).
2026-07-18 23:25:32 -04:00
b0a0ffc1ab fix(bench): gate seeding on repo+index readiness and match read counts semantically
Two benchmark-harness races were being scored as agent failures. A freshly
seeded throwaway repo could 404 for an independent reader (the agent, a fresh
process) before Gitea made it consistent, and the async issue indexer lagged so
`search issues`/`search prs` returned nothing right after seeding — leaving the
agent unable to find the target it was asked to act on.

seedRepo now blocks until an independent read confirms the repo is reachable,
its full seeded issue and pull spread is visible, and a seeded issue and pull
are returned by the search index, before releasing the agent. It polls up to
60s and throws a loud harness error on timeout rather than letting a
propagation delay become a scored agent failure.

Also add an optional `pattern` regex to RequiredFact so a read answer's count is
recognised semantically rather than as a fixed phrase: "5 issues are currently
open" no longer fails against the literal "5 issues are open", while the
alphabetic-only filler run keeps a wrong count beside the right number (e.g.
"5 issues ... 3 open") failing.
2026-07-18 23:25:32 -04:00
662ba82d71 feat: add --comments-file to pr review for inline comments (task 0035)
All checks were successful
CI / test (pull_request) Successful in 55s
CI / test (push) Successful in 53s
`pr review <n>` gains `--comments-file <path>`, a JSON array of inline
comments submitted with the review. Each entry is one of two exclusive
shapes: a new comment `{ path, line, body }` (mapped to `new_position`,
always the new side) or a reply `{ reply_to, body }`. A reply carries no
line or side — gitea-axi finds the target via the reviews-plus-comments
fan-out (there is no get-comment-by-id endpoint), reconstructs its anchor
from the target's own `diff_hunk`, and infers old/new side from it, so a
same-line post threads with the existing conversation. All entries map
onto the review-submission payload's `comments[]`; no new HTTP layer is
added. An unknown `reply_to` is a VALIDATION_ERROR raised before the POST,
and the submitted inline-comment count rides the action block.

The shared path-resolve-and-read behind --body-file and --comments-file is
extracted into src/flag-file.ts.
2026-07-18 16:11:26 -04:00
b6d247dd4e feat: surface inline-comment anchor fields on pr view --reviews (task 0034)
Each inline review comment under `pr view <n> --reviews` now renders its
`id` (the handle a reply targets), `resolved` (`yes`/`no`, from whether
Gitea populated the comment's `resolver`), and `diff_hunk`. The hunk is
structurally trimmed to its `@@` header line plus its last two lines by
default (hunks of three lines or fewer are left whole) and emitted
verbatim under `--full`, so the trim never touches the char-based body
truncation path. The raw `position`/`original_position` diff offsets stay
unsurfaced. The fields ride the existing reviews-plus-per-review-comments
fetch — no extra API calls.
2026-07-18 16:11:26 -04:00
db2285b40d docs: map Gitea web UI vs REST API parity gaps
All checks were successful
CI / test (pull_request) Successful in 58s
CI / test (push) Successful in 54s
Nine capabilities the web UI exposes that the REST API does not, in the
issue/PR/review domain, analysed against go-gitea/gitea at e8befe0.

Each finding cites the web route, the service function its handler
reaches, the swagger evidence, upstream prior art, and a patch sketch.
Ranked by impact and tractability, with issue drafts for the five to
file first.

Also records that resolve/unresolve review conversations and threaded
reply endpoints already exist upstream, which supersedes the premises of
issues #38 and #39.
2026-07-18 15:57:29 -04:00
a557745e59 test: update e2e tracer count-line assertions for task 0033
All checks were successful
CI / test (pull_request) Successful in 55s
CI / test (push) Successful in 56s
The state-aware count line from task 0033 (1166a48) renders
`count: N open of M total`, and the unit tests were updated to match, but
the three e2e tracer assertions still expected the old bare
`count: N of M total`. They are skipped without GITEA_AXI_E2E_URL, so the
staleness only surfaced in CI, where the e2e tier runs. Update them to the
state-qualified form the shipped code already produces — default `open`,
`--state closed` → `closed`. The code was correct; the tests were stale.
2026-07-17 22:52:13 -04:00
040daed39d docs: rewrite bench results for the clean 4-arm snapshot
Some checks failed
CI / test (pull_request) Failing after 54s
The prior results table and narrative claimed gitea-axi posts the lowest
cost-equivalent tokens. That snapshot predated the neutral-working-dir
isolation fix, when the checkout-defaulting arms (gitea-axi, tea) drew
repo and login for free from the harness's own checkout — so gitea-axi
was implicitly pre-authenticated and looked like the winner.

On a clean run with every arm fairly credentialed and executed together,
raw REST is the cheapest on cost-equivalent tokens and leads every tier —
terse HTTP is the token floor no wrapper undercuts. gitea-axi is a clear
second overall and the lowest-cost structured interface, beating gitea-mcp
and tea on every tier at 100% success. Keep the raw-REST arm and state
this plainly rather than crown the wrapper by omitting the floor.

Numbers regenerated from bench:report over the 240-sample clean snapshot.
2026-07-17 22:35:40 -04:00
6e65032944 docs: note the bench runs built dist, not src, in CLAUDE.md gotchas 2026-07-17 18:59:48 -04:00
8653b89612 feat: show issue labels in issue view and add its --fields flag
`issue view` rendered state but never labels, and offered no way to add
them — so reading one issue's labels forced a detour through
`issue list --fields labels` and hunting the matching row. The benchmark
transcripts showed agents paying this round-trip on every labels/state
read.

Show labels by default in the detail view (a detail view should be
complete), and add a `--fields` flag mirroring `issue list` / `search`
to append assignees, closedAt, milestone, updatedAt, url on request.

Also strengthen SKILL.md against the two command-discovery round-trips
the transcripts exposed: name the required `search issues` / `search prs`
subcommand form (a bare `search "<query>"` is invalid), and point agents
straight at `issue view <n>` for a single issue's fields.

Verified live: read-issue-labels-and-state dropped from 10 turns to 4
(cache-read ~3.3x lower), the transcript reduced to three clean commands
with the search-help and issue-list round-trips gone.
2026-07-17 18:59:16 -04:00
ab59e699c1 fix: pre-authenticate the gitea-axi bench arm via its env interface
The gitea-axi arm was the only shell arm handed no credentials: the
runner set only PATH, so the agent had to reverse-engineer the tea-login
system — guessing a profile name and hunting for a config file — before
any real work, burning ~4 turns per task. Since turns drive cache-read,
the benchmark's dominant cost metric, this scaffolding gap alone inflated
gitea-axi's cost-equivalent tokens above every other arm.

Hand the arm its host and token through gitea-axi's own env interface
(GITEA_AXI_API_URL / GITEA_AXI_TOKEN), the symmetric counterpart to the
gitea-mcp server's GITEA_HOST / GITEA_ACCESS_TOKEN env: both name the
same two facts, and both still leave the agent to name the repository per
call. A shell arm now carries a credential env (empty for tea and
raw-api, which need none), merged under PATH in the driver.

Also strengthen SKILL.md so a cold agent targets and authenticates on the
first call: an explicit "Targeting and authentication" section replaces
the buried, optional-looking one-liner, spelling out that outside a
checkout `-R OWNER/NAME` plus the environment's token is all that is
needed — do not go hunting for a config file or login profile.

Verified live: create-memory-leak-issue dropped from 10 turns to 3 and
its cache-read fell ~3.8x, with the auth flailing gone from the transcript.
2026-07-17 15:10:48 -04:00
14d494afa2 feat: persist the tool transcript on every result record
Records stored only a run's token/turn totals, so an arm's turn cost —
the dominant driver of cache-read tokens — could not be diagnosed from
the store. Retain the ordered transcript of tool invocations (the exact
shell commands, MCP calls, and built-in tools the run made) on every
scored record, absent only for a hung run that produced no transcript.

The canonical TranscriptEntry shape lives on the record (result.ts); the
isolation audit's ToolUse now aliases it so the persisted and audited
shapes cannot drift.
2026-07-17 14:43:43 -04:00
05b72f6987 fix: ignore markdown emphasis when matching read answers
Some checks failed
CI / test (pull_request) Failing after 51s
The read checker matched a task's required-fact phrasings as plain
substrings of the agent's report after only lowercasing and collapsing
whitespace. An answer that was substantively correct but wrapped a value in
markdown (e.g. `**5**`) failed the match, because the emphasis markers broke
the phrase adjacency (`**5** open` does not contain `5 open`) — a correct
answer scored incorrect on formatting alone.

Strip markdown emphasis/code markers (`*`, `_`, backtick) during
normalization so the match is on substance, not presentation. A guard test
confirms a wrong value still fails after stripping.
2026-07-17 11:55:53 -04:00
a6ab749211 fix: run the bench agent in a neutral working directory
Some checks failed
CI / test (pull_request) Failing after 52s
The SDK driver ran the agent with no explicit cwd, so its shell inherited
the harness's own checkout. When the agent omitted `-R OWNER/NAME`, the
gitea-axi (and tea) CLI defaulted the repository from that local checkout —
silently resolving the harness repo instead of the seeded throwaway — and
returned a plausible but wrong result (e.g. `count: 0 open of 0 total` for a
repo with no issues). This contaminated read-tier scoring for the checkout-
defaulting arms and was surfaced by the newly persisted read reports.

Give each run a fresh, empty working directory outside any checkout, so a
forgotten `-R` errors instead of hitting the wrong repository, and delete it
when the run ends.
2026-07-17 10:46:45 -04:00
1166a48130 feat: name filtered state in issue list count line (task 0033)
Some checks failed
CI / test (pull_request) Failing after 54s
Make the `issue list` count line name the state it filtered on, so the
answer to "how many issues are open?" is present on the summary line
rather than only inferable from each row. The count line now renders
`count: 5 open of 5 total`; the command composes the state into a generic
optional qualifier while `formatCountLine` stays state-agnostic, so
`pr list`, `search`, and `dashboard` are unaffected. `--state all` imposes
no narrowing and stays unqualified.

The wording is chosen so an agent quoting the summary lands on a phrase the
benchmark read-checker already accepts (`5 open`), closing the accuracy gap
this feature targets. Pairs with the report persistence in task 0032.
2026-07-17 10:09:50 -04:00
4cbed21ff3 feat: persist agent report on read result records (task 0032)
All checks were successful
CI / test (pull_request) Successful in 52s
Retain the agent's final report on the benchmark result record for read
tasks, so a failed read is diagnosable directly from the stored record
instead of only carrying an opaque `incorrect` tag. The runner resolves
the scoring spec once and records `run.finalReport` when the spec is a
read; mutation records omit the field entirely. The sample store needs no
change — it serializes whatever record it is handed.

This is the prerequisite for confirming the read-open-issue-count failure
from real report text before the state-aware count-line change (task 0033).
2026-07-17 09:50:38 -04:00
88 changed files with 5946 additions and 339 deletions

View File

@@ -99,6 +99,8 @@ _Avoid_: depends, depends-on, dependencies
**search**: The full-text query commands (`search issues <query>`, `search prs <query>`), repo-scoped via `owner` param plus [[client-side filtering]] by repository (Gitea's `/repos/issues/search` has no repo-name filter).
Results use a locator schema (`number`, `title`, `state`, `author`, `created`) — search finds the number; `issue view` / `pr view` load the detail.
The [[next-step suggestion]] is conditioned on the in-repo match count: zero matches point at the non-indexed `issue list --state all` / `pr list --state all` fallback ("to list all … instead"), which recovers from both an over-narrow query and issue-indexer lag; exactly one match fills the real number (`issue view <n>`, Principle 9's single-id fill); two or more keep the parameterized `<number>` placeholder.
Search never auto-loads the detail even on a single match — it stays a locator (see ADR 0017).
The forbidden `--search` flag on the list commands redirects here.
_Avoid_: query command, find
@@ -154,16 +156,44 @@ _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 `programs.gitea-axi.enable` is set, which installs the binary unconditionally; a null package is the documented way to declare the context without installing the binary.
The agent context is gated by one [[harness integration toggle]] per harness rather than a toggle per artefact (ADR 0021).
The hook is declared through `programs.claude-code`'s settings option so home-manager merges it with the operator's own; the Skill is written through home-manager's file mechanism directly, which composes with both forms of the operator's own skills option and fixes the path-form collision ADR 0020 could only escape.
_Avoid_: nix module, HM module
**harness integration toggle**: The module option that declares a harness's agent context — for Claude Code, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting on, covering both the [[Agent Skill]] and the [[SessionStart hook]] (ADR 0021).
Named after home-manager's own `enableBashIntegration` convention, so a future harness reads as an `enableCodexIntegration` sibling.
Both artefacts land only when the harness's own module is enabled, silently and without an assertion; the Skill carries an explicit `programs.claude-code.enable` gate because, unlike the hook, it does not inherit that module's own gate.
_Avoid_: skill toggle, hook toggle, per-artefact toggle
**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

@@ -28,3 +28,7 @@ gitea-axi adds the same opt-in `setup hooks`; the skill remains the default `set
Hooks are not the default because the hook runs the dashboard in every session in every directory, and outside a Gitea repo the dashboard errors with `REPO_NOT_FOUND` — a graceful exit-0 degradation was considered and rejected in favor of keeping the error explicit, so hook noise in non-Gitea sessions is an accepted consequence for users who opt in.
The SDK registers the bare binary as the hook command, so the hook always runs the short dashboard tier (see ADR 0012).
**Amended by [ADR 0019](0019-hook-records-search-path-name.md):** that last sentence held only for npm installs.
The SDK records the bare name only when a `PATH` entry realpath-matches the entrypoint it is handed, which npm's symlinked `bin` satisfies and a wrapper-based install cannot.
`setup hooks` now resolves the binary on `PATH` itself and hands that location over, so the bare name is recorded for wrapper-based installs too; the absolute entrypoint path remains the fallback when the name resolves to no install of ours.

View File

@@ -0,0 +1,26 @@
# Search stays a locator; a single match does not auto-load its detail
`search issues` / `search prs` always return a locator list (`number`, `title`, `state`, `author`, `created`) plus a next-step suggestion — never the full detail, even when exactly one result matches.
This deliberately narrows a literal reading of AXI Principle 4 ("eliminate round trips").
## Considered Options
**Auto-collapse to `view` on a single match** (rejected) — On exactly one result, run `issue view` / `pr view` and return the detail record, sparing the agent a second command.
It reads as the purest Principle 4 outcome, and it is what prompted this decision.
But the agent that searches most often wants the *number* to feed a mutation (`edit`, `close`, `comment`), not the body — so auto-loading the detail spends exactly the body tokens Principle 3's truncation exists to avoid, taxing the common find-then-act path to save a step on the rarer find-then-read one.
It also makes the output shape non-uniform — a list for zero and 2+ matches, a detail record for one — which the agent can no longer rely on.
**Stay a locator, suggest the next step** (chosen) — Search's job is finding the number to feed into `view` / `edit` (the spec's locator-schema rationale).
On a single match the next-step suggestion fills the real number (`issue view 2`), applying Principle 9's single-id fill; the agent decides whether that number feeds a `view`, an `edit`, or a `close`.
## The dividing line
Principle 4 eliminates a *redundant* round trip — a mutation returns the entity it just wrote, so no follow-up `view` is needed (ADR 0008).
`search``view` is not redundant: the follow-up is optional and its intent (read vs. act) is the agent's to choose, so collapsing it means guessing intent and over-fetching when the guess is wrong.
## Consequences
- `search` output shape is uniform across all match counts: always a locator list with a `help[N]:` next step.
- The next-step suggestion is conditioned on the in-repo match count: 0 → `list --state all` fallback ("to list all … instead"); 1 → `view <n>` with the real number; 2+ → `view <number>` placeholder.
- The zero-match fallback points at the non-indexed list, so it recovers from both an over-narrow query and issue-indexer lag without the command having to tell the two apart.
- An agent that does want the detail spends one more command (`view <n>`) by design — the number is already in hand, and it pays only for the detail it actually asks for.

View File

@@ -0,0 +1,36 @@
# The Nix wrapper appends `git` and `tea` to PATH rather than prepending them
The Nix-packaged `gitea-axi` binary is wrapped with `makeWrapper --suffix PATH`, not `--prefix`.
The operator's own `git` and `tea` win whenever they are present; the ones from the Nix closure are a fallback that makes the tool work on a machine where neither is installed.
This is the reverse of the usual Nix instinct, which is to pin runtime dependencies so the packaged tool behaves identically everywhere.
## Considered Options
**`--prefix PATH` for both** (rejected) — The hermetic choice: the closure's `git` and `tea` always win, `TEA_NOT_INSTALLED` becomes unreachable, and a half-upgraded system `tea` cannot break gitea-axi.
It fails on `tea` specifically.
Per ADR 0001 as amended, the token comes from `tea login helper get`, which **refreshes near-expiry OAuth tokens in place** — so the invoked `tea` does not merely read `~/.config/tea/config.yml`, it *writes* to it.
Prefixing would put two `tea` versions on one mutable store: the operator's, used interactively for `tea login add`, and the closure's, used for token refresh.
nixpkgs currently carries 0.14.0 while ADR 0001 was verified against 0.14.2, so this is a live version skew, not a hypothetical one.
Divergence in that file surfaces later as an auth failure with no visible connection to its cause.
**`--set PATH`** (rejected) — Fully sealing the environment is defensible in principle, because `src/subprocess.ts` is the single spawn point and invokes only these two binaries, so the surface is small enough to seal.
It inherits every problem above in stronger form, and additionally breaks whatever `git` itself shells out to that is not in the closure: credential helpers, LFS filters, diff and merge drivers, and `ssh` for SSH remotes — which would take `pr checkout` with it.
**`--prefix` for `git`, `--suffix` for `tea`** (rejected) — Puts the hermetic guarantee where state is not shared and defers where it is.
Examined and dropped because the reproducibility it buys on `git` is largely illusory: a pinned `git` still reads the operator's `~/.gitconfig`, so behaviour is not pinned, only the binary is.
Worse, a closure `git` missing an extension the operator relies on can *introduce* the divergence prefixing was meant to prevent.
That leaves a two-rule wrapper paying real explanatory cost for close to nothing.
**`--suffix PATH` for both** (chosen) — One rule, one sentence to explain.
A single `tea` — the one that created the credential store — owns reading and writing it.
The fresh-machine fallback is preserved, so nothing regresses for an operator who has neither binary.
## Consequences
- gitea-axi's behaviour depends on ambient `PATH`, so it is not reproducible across machines in the way a Nix package normally is.
This is accepted deliberately: the tool's job is to drive *the operator's* repositories using *the operator's* credentials, both of which are ambient state already.
- An operator whose `tea` predates the `login helper` interface hits an obscure failure while a working `tea` sits unused in the closure.
Judged acceptable — that interface exists in 0.14.0, the oldest version in nixpkgs.
- The closure carries `git` and `tea` that are usually unused. This is the price of the fallback.
- If the `tea` dependency is ever removed (see ADR 0002's retained credential-discovery role), the argument here collapses to the `git`-only case, and prefixing could be reconsidered — though the `~/.gitconfig` objection would still stand.

View File

@@ -0,0 +1,47 @@
# Record the SessionStart hook as a search-path name, not an install-tree path
`setup hooks` resolves `gitea-axi` on `PATH` and hands that location to `installSessionStartHooks`, so the recorded hook command is the bare name `gitea-axi`.
When the name resolves nowhere on `PATH`, the module-relative entrypoint is handed over instead and the absolute path is recorded, exactly as before.
A `PATH` entry qualifies only when it resolves to the running entrypoint — a symlink pointing at it, or a generated wrapper naming it — so a same-named binary that is some other program does not count.
`setup hooks` also collapses duplicate managed entries itself, recognising its own hook by the exact command it records rather than by a substring of that command.
## Context
The agent SDK's `resolvePortableHookCommand` returns a bare binary name only when some `PATH` entry *realpath-matches* the entrypoint it is handed, and the absolute path otherwise.
Task 0042 verified that this splits the two installation methods: npm symlinks its `bin` entry straight at `dist/main.js` so the match succeeds, while any wrapper-based install cannot match, because a script that *invokes* a file never resolves *to* that file.
Under Nix the recorded path is content-addressed — it changes on every rebuild and is eventually garbage-collected — and a SessionStart hook that cannot execute does not run and does not warn.
## Considered Options
**Document a re-run after upgrade** (rejected; this was the task 0042 mitigation being replaced) — Documentation against a silent failure is the weakest kind of fix.
It also does not work reliably: the re-run may leave the stale entry behind rather than replacing it, so the help text had to caveat its own remedy.
**Detect store paths in application code** (rejected) — Special-casing Nix in the CLI is the wrong shape.
The problem is not Nix; it is every wrapper-based install — a shim, a launcher, a generated `.cmd`.
**Write the hook files directly, bypassing the SDK** (rejected) — Would duplicate the SDK's handling of three integrations and four files to change one string, and would drift from it on every SDK change.
**Hand the SDK the `PATH` location** (chosen) — The SDK already prefers the search-path name; it was only ever reaching for it from the wrong end.
Resolving the name the way a shell does and handing that over makes the SDK's own realpath test succeed, so the preference becomes reachable for every installation method rather than only for the symlink shape npm happens to use.
Recording a bare name and letting `PATH` resolve it is also the convention for tools writing into user-owned configuration; absolute paths belong in configuration a package manager regenerates.
## Consequences
- The hook survives an upgrade whenever the binary is on `PATH`, so `setup hooks` no longer needs re-running after one, and the help text saying so is gone.
- The absolute path remains the documented fallback for a binary that is not on `PATH` — a source checkout run through `node dist/main.js`, say — where it is the only thing that could work.
- Which command gets recorded now depends on the invoking environment's `PATH`, not only on how the package was installed.
`PATH` is therefore read from the process rather than from the injected environment, since it must agree with the SDK's own probing.
- A same-named binary on `PATH` that is *not* this program does not qualify.
The name must resolve to the running entrypoint — by realpath for a symlink, or by the wrapper naming it — or the fallback applies.
Accepting any file that merely bears the name would make the SDK's realpath test a tautology, since the path handed over would trivially match itself.
- The SDK recognises its managed hook by finding the marker inside the recorded command, which made a re-run append a duplicate whenever the entrypoint path lacked the substring `gitea-axi`.
`setup hooks` now prunes duplicates by matching the exact command it records, so idempotency no longer depends on the recorded command's shape, and another tool's hook can never be mistaken for ours.
- `package.nix` no longer renames its build tree in `postUnpack`.
That rename existed only to give the fast tier an entrypoint path containing the marker.
With the coupling gone the build runs from `/build/source` and the idempotency test passes there, which is what demonstrates the coupling is actually broken.
- ADR 0009's addendum claimed "the SDK registers the bare binary as the hook command".
That was true only for npm installs; as of this decision it is true for any install whose `PATH` entry resolves to this entrypoint.
ADR 0009 is amended accordingly.

View File

@@ -0,0 +1,69 @@
# Ship a home-manager module for gitea-axi's ambient context
The flake exposes `homeModules.gitea-axi`, a home-manager module that declares the bundled Agent Skill and the SessionStart hook, installing the package by default.
Importing it does nothing; `programs.gitea-axi.enable` switches it on, and the Skill and the hook each carry their own toggle, both defaulting on.
The package publishes what the module consumes: the Skill at a stable address in the output, and the SessionStart hook entry read from `session-start-hook.json`, a committed file the fast tier reads too.
`setup` and `setup hooks` are unchanged and remain fully supported.
The two paths are alternatives, not stages.
## Context
The [nix-flake-packaging spec](../spec/nix-flake-packaging.md) listed a home-manager module under Out of Scope, deferred rather than rejected, on the grounds that managing the Agent Skill declaratively "would reintroduce exactly the automatism that ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script", and that the trade-off deserved its own decision made with usage evidence.
The evidence arrived, and it does not support the deferral's reasoning.
`setup` and `setup hooks` are write-only against files the operator is assumed to own.
An operator whose agent configuration is generated declaratively cannot use either: the targets are read-only, which task 0044 made fail cleanly rather than obscurely, but failing cleanly is not the same as working.
What that operator does instead is hand-copy the Skill into their own configuration, where it silently drifts from the package that ships it — the exact failure the bundled Skill exists to prevent.
The deferral's stated concern also does not survive contact with what a module is.
ADR 0009 rejected a *postinstall script*: something that runs without being asked, as a side effect of installing a package.
A module the operator imports and then explicitly enables is the opposite — it is the declarative spelling of running `setup`, not a substitute for the operator's consent.
The automatism ADR 0009 guards against is absence of intent, and an `enable` option is intent.
## Considered Options
**Keep deferring** (rejected) — The deferral was conditional on evidence, and the evidence is in.
Leaving it deferred means the one operator the whole spec was written for still installs the Skill by hand.
**Teach `setup` to emit a Nix expression** (rejected) — A code generator producing configuration the operator then commits.
It inverts the dependency: the generated expression is a snapshot that goes stale the moment the package changes, which is the drift problem restated rather than solved.
**Write `~/.claude/settings.json` and the Skill from `home.file` directly** (rejected) — The straightforward spelling, and it collides head-on with `programs.claude-code`, which owns `settings.json`.
An operator using both modules would get a home-manager conflict on that file, and the composition the module exists to provide would be exactly what it broke.
**Declare through the Claude Code module's own options** (chosen) — `programs.claude-code.skills.gitea-axi` and `programs.claude-code.settings.hooks.SessionStart`.
Home-manager's own merge semantics then do the composing: attribute sets merge, lists concatenate, and an operator who already declares their own Skills and SessionStart hooks gets ours alongside theirs rather than instead of them.
The cost is a dependency on that sibling module being enabled, which is asserted rather than assumed.
## Consequences
- The module is a wiring layer with no content of its own.
Everything it declares comes from the package's `passthru`, so the declarative and imperative paths install the same two artefacts by construction.
- `session-start-hook.json` is the hook's single declaration.
The Nix expression reads it and the fast tier reads it, and that tier drives `setup hooks` against a temporary home and asserts the written entry equals the declared one.
A divergence — including the agent SDK changing the envelope it writes — fails a test instead of shipping.
Declaring the entry in the Nix expression instead would have created a second source of truth with nothing checking it against the first, and a test restating the same values a third time would have verified nothing.
- The Skill is installed to `share/gitea-axi/skills/gitea-axi` in the output and published as `passthru.skill`.
Its other copy, inside the installed node modules tree where `setup` resolves it relative to its own module, is an implementation detail of runtime resolution that moves with the packaging method; no consumer should address it.
- `package = null` means the binary is not added to `home.packages`, for an operator installing it through `environment.systemPackages` instead.
It does not mean the configuration is empty: the Skill still comes from the default build, which in that arrangement is already in the closure.
The hook is unaffected either way, since it records a name resolved on `PATH` (ADR 0019) rather than a store path.
- Home-manager inspects the Skill path while evaluating, so a rebuild realises the package during evaluation rather than at build time — including under `package = null`, where nothing is being installed.
That is inherent to sourcing the Skill's bytes from the package: any store path handed to `programs.claude-code.skills` has the same effect.
It is a cost in rebuild latency, not in correctness, and pointing the module at the repository's own `skills/` directory to avoid it was rejected — that would ignore `package` entirely, so an operator running an override would get a Skill from a build they are not running.
- `programs.claude-code.skills` also accepts a single path standing for a whole skills directory, and a configuration using that form cannot have an entry merged into it.
Such a configuration gets a type-merge error rather than composition.
This is documented in [INSTALL.md](../../INSTALL.md) rather than worked around, since the workaround would mean writing the Skill file directly and reintroducing the collision this decision avoids.
- The module defaults `package` to `pkgs.callPackage ./package.nix { }` — the importing configuration's own package set, not this flake's nixpkgs.
That is how a consumer deduplicates, and it is why the derivation is a callable expression rather than a flake-bound one.
- Enabling the Skill or the hook without `programs.claude-code.enable` is an assertion failure rather than a silent no-op.
Those options are written by a module that is gated on its own `enable`, so without the assertion an operator would get a configuration that says the Skill is installed and a session that never sees it.
- `nix flake check` does not evaluate the module, because doing so would mean taking home-manager as a flake input purely to test against.
This matches the spec's existing position that the flake's consumption from a system configuration is verified by the maintainer's rebuild rather than by an automated test: building the package proves the derivation, and whether the configuration wires it in is outside this repository.
The module was verified before landing against real home-manager, including that importing it without enabling it yields a byte-identical generation to never importing it at all.
- The two Codex integrations `setup hooks` writes — `~/.codex/hooks.json` and `~/.codex/config.toml` — and the OpenCode plugin file have no declarative counterpart here.
Home-manager has no module owning those files, so declaring them would mean writing them directly and re-creating the collision problem this decision avoids for Claude Code.
An operator wanting those on a declarative system uses `setup hooks`, whose targets are unmanaged there and therefore writable.

View File

@@ -0,0 +1,79 @@
# Declare the Agent Skill through home.file and gate integration per harness
The home-manager module installs the gitea-axi binary whenever `programs.gitea-axi.enable` is set, and declares the Claude Code Agent Skill and SessionStart hook under a single per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting on.
Those two artefacts land only when `programs.claude-code.enable` is also on, and are silently absent otherwise — there is no assertion.
The Skill is written through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, rather than by contributing to `programs.claude-code.skills`.
The hook is still declared through `programs.claude-code`'s settings option, unchanged.
This supersedes three decisions of [ADR 0020](0020-home-manager-module-for-declarative-context.md): the per-artefact toggles and their assertion, the Skill's declaration through the Claude Code module's skills option, and the position that the flake takes no home-manager input.
The rest of ADR 0020 stands.
## Context
ADR 0020 gave the Skill and the hook one toggle each, both defaulting on, and made enabling either without `programs.claude-code.enable` an assertion failure.
The assertion fires on the common case.
An operator who wants gitea-axi as a standalone CLI on a host without Claude Code has not made a mistake, but the module meets them with a build failure telling them to turn off two toggles.
Reaching the standalone shape means setting the module's `enable` to whatever `programs.claude-code.enable` is, and then setting `package = null` so the module does not install a binary the operator installs itself — leaving an `enable` that does not mean "enabled" and a `null` package that does not mean "no package".
Underneath that is a composition failure ADR 0020 documented as a limitation rather than fixed.
`programs.claude-code.skills` accepts either an attribute set of skills or a single path standing for a whole skills directory.
Contributing the module's Skill as an attribute entry cannot merge with an operator who set the option to a path: the two are different branches of the option's type, and the evaluation fails.
ADR 0020's remedy was to disable the Skill and place it by hand, which reintroduces the hand-copying-that-drifts the bundled Skill exists to prevent.
The two problems share a root.
The Skill was routed through a sibling module's typed option, so it inherited that option's merge behaviour — including the branch that cannot merge — and the whole integration was gated by an assertion because the declaration's only enforcement point was that assertion.
## Considered Options
**Default the toggles to `programs.claude-code.enable`** (rejected) — The upstream consumer's own suggestion.
It keys an option's default off a sibling module's config, which is the config-derived default the operator found objectionable, and it does not generalise to multiple harnesses without each toggle reaching into a different sibling.
Its stated diagnosis was also wrong: the friction is the assertion firing on the common case, not the module's `config` block sitting inside its own `enable` gate, which is unremarkable.
**Keep the per-artefact toggles, drop the assertion to a warning** (rejected) — Leaves the Skill routed through `programs.claude-code.skills`, so the path-form collision remains, and keeps a per-artefact surface that does not generalise to harnesses whose artefacts differ from Claude Code's.
**Write the Skill and the settings file directly, bypassing both sibling options** (rejected for the hook, adopted for the Skill) — ADR 0020 rejected this as one option, on the grounds that it collides with `programs.claude-code` owning the settings file.
That reasoning is sound for the hook and false for the Skill.
The settings file is a single file the Claude Code module owns wholesale, so writing it directly collides; Skill files are per-path, so writing the Skill's own path collides with nothing the operator has not themselves put there.
ADR 0020 bundled the Skill into a rejection only its sibling earned.
**Declare the Skill through home.file and gate integration per harness** (chosen) — The Skill is written as an ordinary file declaration at its own path, which never touches the skills option's type and so composes with both the attribute-set and whole-directory forms.
The per-artefact toggles collapse into one per-harness toggle whose name follows home-manager's `enableBashIntegration` convention and leaves room for sibling harnesses.
The assertion is removed: writing into a disabled sibling's option is a benign no-op by home-manager convention, and the Skill's own gate makes its absence equally silent.
## Consequences
- The Skill composes with an operator's own skills whichever form they use.
Under the attribute-set form the Claude Code module lowers each skill to its own file declaration, so the module's Skill is one more entry at a distinct name.
Under the whole-directory path form that module installs the directory recursively — individually-linked files under a real directory — so the module's nested Skill entry drops in as a sibling.
This was verified by building the home files derivation under both forms and confirming all entries coexist.
- The composition under the path form couples to the Claude Code module installing a path-form skills directory recursively.
A change there to a non-recursive install would claim the whole skills directory as one link and collide with the module's nested Skill as a build-time file conflict.
The failure would be loud and immediate rather than silent, and the flake check below is what would catch it.
- The Skill no longer inherits the Claude Code module's `enable`-gate, because it is declared by this module's own file mechanism rather than through that module's options.
The module therefore gates the Skill write on `programs.claude-code.enable` explicitly, alongside `programs.gitea-axi.enable` and the integration toggle.
This keeps the intended semantics — no Skill on a host without Claude Code — and keeps the package realisation lazy, since the file mechanism reads the Skill's source path while evaluating and an ungated declaration would realise the package on every host, even one installing nothing.
- The hook keeps its gate for free.
It is declared through the Claude Code module's settings option, which that module drops and never forces when it is disabled.
The two artefacts are thus gated by different mechanisms — the Skill by an explicit sibling-enable condition, the hook by the sibling module's own gate — and the module comments the asymmetry so it is not mistaken for an oversight.
- Granularity is per harness, not per artefact.
One toggle installs both Claude Code artefacts, and future harnesses read as `enableCodexIntegration` and `enableOpenCodeIntegration` siblings.
The per-artefact escape hatch ADR 0020 offered — disable the Skill, keep the hook — is no longer needed, because the case it existed for was the path-form collision, and that collision is now fixed rather than escaped.
- The integration toggle defaults to a literal `true`, not to `programs.claude-code.enable`.
The declared value stays constant, and the honest gating lives at the point of declaration rather than in a config-derived default.
- `nix flake check` now evaluates the module.
A home-manager input is added, with its nixpkgs following the flake's, so the module is checked against the same pairing a consumer following this flake would get.
This reverses ADR 0020's position that the flake takes no home-manager input and the module is verified only by a maintainer rebuild.
The reversal buys the first automated proof of the module's composition — the path-form coexistence, the attribute-set coexistence, the disabled-Claude-Code gating, and the hook merging into an operator's own hooks — where a rebuild proved it only after the fact and only on the maintainer's own configuration.
- `package = null` is unchanged.
It still declares the Skill and hook from the default build without adding the binary to the operator's packages, and the fallback that reads the default build's Skill is kept.
Under the new gating that fallback is reached only when Claude Code is enabled, so the upstream suggestion to revisit it as a wasteful second evaluation does not apply.
- ADR 0020's surviving decisions are unaffected: the hook declared through the Claude Code settings option, the module as a content-free wiring layer sourcing both artefacts from the package, the `hook specification` as the hook's single committed source of truth checked by both paths, and the absence of a declarative counterpart for Codex and OpenCode.

377
.claude/gitea-web-vs-api.md Normal file
View File

@@ -0,0 +1,377 @@
# Gitea: Web UI vs REST API Parity Gaps
Capabilities that Gitea's web UI exposes but its REST API does not, in the issue / pull request / review / comment / label / milestone domain.
Every finding is framed as an **API-consumer parity** claim — "the web UI can do X, the API cannot" — because that is the argument that stands on Gitea's own terms, independent of any particular client.
## Provenance
Analysed against `go-gitea/gitea` at commit `e8befe026853a90a9efca559ca9f9cac8b41fc88` (2026-07-18).
Claims below cite that tree; re-verify before filing if upstream has moved.
Method: every web route registered under the issue/PR paths in `routers/web/web.go` was paired against `routers/api/v1/api.go`, then each unpaired route was traced to the `services/` or `models/` function its handler calls, and that function checked for reachability from any API handler.
Each surviving finding was cross-checked against `templates/swagger/v1_json.tmpl`, Gitea's generated OpenAPI spec.
Excluded by design: presentation-only routes (HTMX partial re-renders, template fragments, sort/filter preferences, diff view-style toggles) — they carry no server-side capability.
Also out of scope for this pass: projects/kanban boards and issue/PR templates, which are separate domains deserving their own sweep.
### Already closed upstream
Two gaps that motivated this review turned out to be **already fixed on main** and are not contribution targets:
- Resolving and unresolving review conversations — `POST /repos/{owner}/{repo}/pulls/comments/{id}/resolve` and `/unresolve` exist (`routers/api/v1/api.go`).
- Replying to an inline review comment — `POST /repos/{owner}/{repo}/pulls/{index}/comments/{id}/replies` exists.
Inline review comments can also be created through `POST /pulls/{index}/reviews` with a `comments[]` array.
What remains missing on that front is narrower and is recorded as finding 2.
## Findings, ranked
Ranked by blast radius across API consumers, tiebroken by how mechanical the fix looks.
| # | Gap | Class | Tractability | Prior art |
|---|-----|-------|--------------|-----------|
| 1 | Merge blockers not exposed | response-shape | medium | open issue #13879 (2020, no attempt) |
| 2 | Cannot accumulate a pending review | missing route | medium | closed #15933; wishlist #32898 |
| 3 | Review comments never report `invalidated` | response-shape | trivial | none |
| 4 | Time estimates readable but not writable | underpowered | trivial | none; read half merged as #35475 |
| 5 | No batch issue operations | missing route | large | none |
| 6 | Content edit history entirely absent | missing route | medium | open issue #6454 (2019, 13 reactions) |
| 7 | Review comments cannot carry attachments | underpowered | small | none; web half merged as #29220 |
| 8 | Per-user "viewed files" state is web-only | missing route | small | open issue #32898 |
| 9 | Label-set initialization is web-only | missing route | trivial | partial: #24602, #6061 |
No stalled or closed-unmerged PR exists for any of the nine, with one exception ([#36903](https://github.com/go-gitea/gitea/pull/36903), closed on API shape rather than on principle).
There is no abandoned branch to revive, and no record of a maintainer having rejected any of these on design grounds.
### Recommended filing order
The ranking above measures the size of each gap.
Filing order should follow the strength of the maintainer signal instead, which points somewhere different:
1. **Finding 4** — merged PR #35475 exposed `time_estimate` for reading and stopped; a write route completes work maintainers already accepted, reusing an existing service function. Lowest-risk first contribution.
2. **Finding 7** — same shape: merged PR #29220 built the capability and missed the API struct. An oversight fix, not a proposal.
3. **Finding 3** — no prior art at all, trivial patch, with #15167 as a precedent for adding fields to this exact struct.
4. **Finding 1** — the largest gap, but a six-year-old issue with no maintainer signal. File as a comment on #13879 with the structured-blocker proposal.
5. **Findings 2, 6, 8** — each has a live issue (#32898, #6454) to comment on rather than duplicate.
6. **Finding 5** — open as a discussion first; the request shape is a real design decision.
7. **Finding 9** — file only if the others land; it is the weakest of the nine.
---
### 1. Merge blockers are not exposed; the API returns a bare `mergeable` boolean
**Class**: response-shape omission.
**Web**: `GET /{owner}/{repo}/pulls/{index}/merge_box``ViewPullMergeBox` (`routers/web/repo/issue_view.go:428`), populating `pullMergeBoxData` (`routers/web/repo/pull.go:290`).
**Capability reached**: that struct carries `isBlockedByApprovals`, `isBlockedByRejection`, `isBlockedByOfficialReviewRequests`, `isBlockedByOutdatedBranch`, `isBlockedByChangedProtectedFiles` (`routers/web/repo/pull.go:303-309`), plus status-check state and required-signing state, all derived from the protected branch rule and `services/pull`.
**Current API**: `PullRequest.Mergeable bool` (`modules/structs/pull.go`) — one boolean, no reason.
**Swagger evidence**: `merge_box` appears zero times in `templates/swagger/v1_json.tmpl`; the `PullRequest` definition carries no blocker fields.
**Parity impact**: any consumer automating merges — CI bots, merge queues, dashboards, third-party clients — can see *that* a PR is unmergeable but never *why*.
The choice is to re-implement Gitea's branch-protection logic client-side against several other endpoints, or to surface a merge button that fails with no explanation.
This is the single largest structural asymmetry in the PR domain.
**Patch sketch**: the blocker computation currently lives in the web layer and would need extracting into `services/pull` as a function returning a structured result, leaving `ViewPullMergeBox` a thin caller.
Then either add the fields to `PullRequest` under `omitempty` (cheap, but computed on every PR read — likely too expensive for list endpoints) or add a dedicated `GET /repos/{owner}/{repo}/pulls/{index}/merge_status` returning the structured blockers.
The dedicated endpoint is the defensible proposal; the struct change invites a performance objection on `ListPullRequests`.
**Prior art**: open issue [#13879](https://github.com/go-gitea/gitea/issues/13879), "[api] pull request field Mergeable = ture but not aproved" — open since 2020, labeled `topic/api`, asking for exactly this ("an extra field witch show if a pull is realy ready to merge (required ci passed & required reviews)").
Six years open with no implementation attempt and no maintainer resolution.
Supporting: open issue [#25849](https://github.com/go-gitea/gitea/issues/25849) reports the `mergeable` boolean holding a stale `false` after a conflict is resolved — a correctness bug rather than this request, but useful evidence that the single boolean is inadequate.
No PR has ever proposed surfacing the blocker reasons.
Adjacent but distinct: open PR [#38404](https://github.com/go-gitea/gitea/pull/38404) exposes scheduled auto-merge via the API, not merge blockers.
---
### 2. A pending review cannot be accumulated across calls
**Class**: missing route.
**Web**: `POST /{owner}/{repo}/pulls/{index}/files/reviews/comments``CreateCodeComment` (`routers/web/repo/pull_review.go`), bound to `forms.CodeCommentForm` whose `SingleReview` flag decides whether the comment lands in the doer's open pending review or stands alone.
**Capability reached**: `pull_service.CreateCodeComment(..., pendingReview bool, replyReviewID int64, ...)` (`services/pull/review.go:116`).
**Current API**: `POST /pulls/{index}/reviews``CreatePullReview` (`routers/api/v1/repo/pull_review.go:466`) creates the comments with `pendingReview=true` and then immediately calls `pull_service.SubmitReview` in the same request.
There is no `POST /pulls/{index}/reviews/{id}/comments`, so a review cannot be opened, added to over several calls, and submitted later — the entire review must be assembled in one request body.
**Swagger evidence**: no route under `reviews/{id}` accepts comments; `GET .../reviews/{id}/comments` is read-only.
**Parity impact**: a reviewer client that walks a diff file by file — which is how both humans and automated reviewers work — cannot mirror the web UI's incremental flow.
It must buffer every comment in its own memory and hope the single submitting request succeeds, with no server-side draft to recover if it does not.
**Patch sketch**: add `POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments` accepting the existing `CreatePullReviewComment` shape, validating that review `{id}` belongs to the doer and is in pending state, then calling `pull_service.CreateCodeComment` with `pendingReview=true` and the review id.
The service function already takes every argument required; this is routing and validation only.
A companion `POST /pulls/{index}/reviews` with `event: PENDING` returning the open review already exists as the entry point.
**Prior art**: closed issue [#15933](https://github.com/go-gitea/gitea/issues/15933), "[API] Cannot create a pending review without body (but with a file comment)" — the reporter's stated goal was to "initiate pending reviews with file-specific comments, then publish the review later via submission", i.e. this exact workflow.
It was labeled `issue/confirmed` and closed without that workflow being delivered.
This finding needs the most careful framing of the nine, because two recent changes look like they already solved it and do not:
- Merged PR [#36683](https://github.com/go-gitea/gitea/pull/36683) added `POST .../pulls/{index}/comments/{id}/replies`, which replies to an *already-posted* comment; its own notes state that "reply-only requests skip creating pending reviews".
- PR [#36903](https://github.com/go-gitea/gitea/pull/36903) proposed an `in_reply_to` field and was closed unmerged as a duplicate, on API *shape* grounds (silverwind preferred keying off `comment_id` to match GitHub) rather than on the capability being unwanted — a favourable signal for a fresh proposal.
Lead any filing with the `single_review=false` web-route contrast so a skimming reviewer does not mistake it for #36683.
Also relevant: open issue [#32898](https://github.com/go-gitea/gitea/issues/32898) ("expansions to the pull review API") collects a wishlist of review-API additions and is the natural place to raise this alongside finding 8.
---
### 3. Review comments never report whether they are outdated
**Class**: response-shape omission.
**Web**: the diff view branches on outdated state throughout (`SetShowOutdatedComments` middleware on the `pulls/{index}/files` routes in `routers/web/web.go:1645-1651`).
**Capability reached**: `issues_model.Comment.Invalidated bool` (`models/issues/comment.go:317`), set when the commented line no longer exists in the current diff.
**Current API**: `PullReviewComment` (`modules/structs/pull_review.go`) exposes `Resolver` but not `Invalidated`, and carries no review-thread grouping identifier.
**Swagger evidence**: `invalidated` appears zero times in `templates/swagger/v1_json.tmpl`.
**Parity impact**: a consumer listing review comments cannot distinguish live feedback from comments stranded by subsequent pushes, so it either surfaces stale review threads as actionable or silently drops them.
The web UI hides them behind a toggle precisely because the distinction matters.
**Patch sketch**: add `Invalidated bool \`json:"invalidated"\`` to `PullReviewComment` and populate it in `ToPullReviewComment` (`services/convert/pull_review.go:103`), alongside the existing `Resolver` assignment on line 108.
Roughly a three-line change plus the swagger regeneration.
An explicit `resolved bool` could ride along in the same patch, since `Resolver != nil` is currently the only way to infer it.
**Prior art**: none — the cleanest filing of the nine.
No issue or PR has ever asked for `invalidated` on the API struct, and none has asked for a review-thread grouping identifier.
Existing work on `Invalidated` is confined to the model and UI layers ([#8751](https://github.com/go-gitea/gitea/pull/8751), [#12548](https://github.com/go-gitea/gitea/pull/12548)[#12550](https://github.com/go-gitea/gitea/pull/12550)).
There is a precedent chain worth citing: merged PR [#15167](https://github.com/go-gitea/gitea/pull/15167) added the `resolver` object to this exact struct — and its author noted in passing that "only the first comment of a conversation might have a resolver, the others seem to be always nil", which is the missing thread-grouping identifier being observed and left alone.
Merged PR [#36441](https://github.com/go-gitea/gitea/pull/36441) then added the resolve/unresolve routes without adding a `resolved` field; verified against `modules/structs/pull_review.go` at this commit, the struct still carries only `Resolver`.
Fields do get added to `PullReviewComment` when someone asks.
---
### 4. Time estimates are readable but not writable
**Class**: underpowered — the field exists in responses with no write path.
**Web**: `POST /{owner}/{repo}/{type:issues|pulls}/{index}/time_estimate` → `UpdateIssueTimeEstimate` (`routers/web/repo/issue_timetrack.go`).
**Capability reached**: `issue_service.ChangeTimeEstimate` (`services/issue/issue.go:130`).
**Current API**: none.
`EditIssueOption` (`modules/structs/issue.go`) has no time-estimate field, and no dedicated route exists.
**Swagger evidence**: `time_estimate` appears exactly once in `templates/swagger/v1_json.tmpl` — as an `int64` property on the `Issue` schema, i.e. read-only by construction.
**Parity impact**: a field the API hands back cannot be set through the API.
Any planning or import tool must either leave estimates empty or drive the web form, and round-tripping an issue through the API silently drops the estimate.
The read/write asymmetry makes this an easy argument: the API already acknowledges the concept.
**Patch sketch**: the service function exists and is already permission-checked at the caller.
Either add `TimeEstimate *int64` to `EditIssueOption` and call `issue_service.ChangeTimeEstimate` from `EditIssue`, or mirror the web layout with `POST /repos/{owner}/{repo}/issues/{index}/time_estimate`.
The `EditIssueOption` route is preferable — it matches how `deadline` and `ref` are already handled and adds no new path.
**Prior art**: no issue or PR has ever asked for the write side.
The read side is merged PR [#35475](https://github.com/go-gitea/gitea/pull/35475), "Exposing TimeEstimate field in the API" (September 2025), which added `time_estimate` to the issue API response and to webhooks — and stopped there.
That is the strongest maintainer signal in this document: they have already accepted exposing this field through the API and simply implemented half of it.
A write route completing work they merged is an easy review.
Merged PR [#38423](https://github.com/go-gitea/gitea/pull/38423) (July 2026) recently hardened the `util` duration parser a write endpoint would reuse.
Tangential only: open issue [#33318](https://github.com/go-gitea/gitea/issues/33318) is about the web UI's estimate widget, and [#23112](https://github.com/go-gitea/gitea/issues/23112) is the closed original feature request.
---
### 5. No batch issue operations
**Class**: missing route.
**Web**: the issue list acts on many issues per request — `POST /{owner}/{repo}/{type}/status` (`UpdateIssueStatus`, `routers/web/repo/issue_list.go:402`), `POST /{owner}/{repo}/{type}/labels` (`UpdateIssueLabel`, `routers/web/repo/issue_label.go:167`), `POST /{owner}/{repo}/{type}/delete` (`BatchDeleteIssues`, `routers/web/repo/issue_list.go:387`), plus batch milestone and assignee updates registered alongside them (`routers/web/web.go:1373-1379`).
**Current API**: every one of these is per-issue only — `PATCH /issues/{index}`, `POST|DELETE /issues/{index}/labels`, `DELETE /issues/{index}`.
**Swagger evidence**: no bulk route exists in the spec under `/repos/{owner}/{repo}/issues`.
**Parity impact**: triaging fifty issues costs fifty round trips and fifty webhook deliveries, against one in the browser.
On instances with rate limiting in front of them, bulk triage via API is effectively impractical — the exact workload automation exists to serve.
**Patch sketch**: this is the least mechanical of the nine.
The web handlers take a form-encoded issue-id list and are entangled with redirect/flash behaviour, so extraction into `services/issue` comes first.
A clean proposal would add `POST /repos/{owner}/{repo}/issues/batch` taking `{ "issues": [1,2,3], "state": "closed", "add_labels": [...], "remove_labels": [...], "milestone": N, "assignees": [...] }` with per-item results so partial failures are reportable.
Worth opening as a discussion issue before writing code — the request shape is a genuine design decision and a maintainer will have opinions.
**Prior art**: none.
Every bulk-issue item upstream is web-UI-scoped — the requests that *built* the UI's bulk actions, not requests to expose them: [#18883](https://github.com/go-gitea/gitea/issues/18883) (bulk select), [#22273](https://github.com/go-gitea/gitea/issues/22273) (delete multiple, which drove `BatchDeleteIssues`), [#17216](https://github.com/go-gitea/gitea/issues/17216) (mass-assign to project), plus open UI bugs [#24185](https://github.com/go-gitea/gitea/issues/24185) and [#24651](https://github.com/go-gitea/gitea/issues/24651).
Useful precedent that the pattern is acceptable in principle: batch APIs have been requested for other resources — open issue [#33611](https://github.com/go-gitea/gitea/issues/33611) (batch repository imports) and closed [#22138](https://github.com/go-gitea/gitea/issues/22138) (batch change file API).
Nobody has ever raised it for issues.
---
### 6. Issue and comment content edit history is entirely absent
**Class**: missing route.
**Web**: four handlers in `routers/web/repo/issue_content_history.go` — `GetContentHistoryOverview`, `GetContentHistoryList`, `GetContentHistoryDetail`, `SoftDeleteContentHistory` — registered at `routers/web/web.go:1301-1305` and `:1367`.
**Capability reached**: `models/issues/content_history`, which records every edit of an issue or comment body.
**Current API**: none.
**Swagger evidence**: `content_history` and `content-history` each appear zero times in `templates/swagger/v1_json.tmpl`.
**Parity impact**: the API presents issue and comment bodies as if they had no history, while the database and the web UI both know otherwise.
Audit tooling, compliance exports, and migration tools cannot see that a body was edited, let alone what it said before — and `content_version` is already exposed on `EditIssueOption` for conflict detection, so the API half-acknowledges versioning while offering no way to read it.
Sharper still: since the fix for [#30807](https://github.com/go-gitea/gitea/issues/30807), API-driven edits *do* write content-history rows.
The API therefore produces history it cannot read back.
**Patch sketch**: add read routes mirroring the web handlers under `GET /repos/{owner}/{repo}/issues/{index}/content-history` (list) and `.../content-history/{id}` (detail), with the comment variants under the existing comment paths.
The model layer needs no change; the work is converters for the history records plus the same permission checks `canSoftDeleteContentHistory` already encodes.
Propose read-only first — soft-delete is a separate and more contentious surface.
**Prior art**: open issue [#6454](https://github.com/go-gitea/gitea/issues/6454), "Expose issue edition through the API" — open since March 2019, 13 positive reactions, still seeing activity in April 2026, filed by the git-bug author who wants edit history for an offline-capable bridge and cites GitHub's GraphQL `userContentEdits` as the model.
Seven years open, no PR ever attempted.
Comment on it rather than filing a duplicate.
Reinforcing: closed issue [#30807](https://github.com/go-gitea/gitea/issues/30807) reported that API-driven edits were not recording history rows at all, fixed by merged PRs [#30814](https://github.com/go-gitea/gitea/pull/30814) and [#30845](https://github.com/go-gitea/gitea/pull/30845).
So the API now *writes* content history it still cannot read back — a sharper framing of the gap than the one above.
---
### 7. Review comments cannot carry attachments
**Class**: underpowered.
**Web**: `forms.CodeCommentForm.Files` (`services/forms/repo_form.go`) is passed straight through by `CreateCodeComment` (`routers/web/repo/pull_review.go`).
**Capability reached**: `pull_service.CreateCodeComment(..., attachments []string)` (`services/pull/review.go:116`).
**Current API**: `CreatePullReviewComment` (`modules/structs/pull_review.go`) has only `Path`, `Body`, `OldLineNum`, `NewLineNum`.
The API always passes a nil attachment list.
**Swagger evidence**: no attachment field on the review-comment request definitions.
**Parity impact**: inconsistent with the rest of the API, which supports attachments on issues and issue comments through the `/assets` routes.
A reviewer client cannot attach a screenshot or log to inline feedback, though the service layer accepts one.
**Patch sketch**: add `Attachments []string` to `CreatePullReviewComment` (and to the reply options), pass it through in `CreatePullReview` at `routers/api/v1/repo/pull_review.go:466` and in `CreatePullReviewCommentReply` at `:209`.
Uploads already have a route; this only carries the resulting UUIDs.
**Prior art**: none for the API side.
Merged PR [#29220](https://github.com/go-gitea/gitea/pull/29220), "Add attachment support for code review comments" (February 2024), is the change that added the `attachments []string` parameter to `pull_service.CreateCodeComment` and wired the web form to it, resolving web-side requests [#27960](https://github.com/go-gitea/gitea/issues/27960), [#24411](https://github.com/go-gitea/gitea/issues/24411) and [#12183](https://github.com/go-gitea/gitea/issues/12183).
It did not touch `CreatePullReviewComment` — the asymmetry is a straightforward oversight in an otherwise complete feature, which is the easiest kind of gap to get accepted.
Note that [#32898](https://github.com/go-gitea/gitea/issues/32898)'s review-API wishlist does *not* mention attachments, so this one stands alone.
---
### 8. Per-user "viewed files" state is web-only
**Class**: missing route.
**Web**: `POST /{owner}/{repo}/{type:pulls}/{index}/viewed-files` → `UpdateViewedFiles` (`routers/web/repo/pull_review.go:303`), registered at `routers/web/web.go:1347`.
**Capability reached**: the per-user, per-file reviewed-state records backing the diff view's viewed checkboxes.
**Current API**: none, for reading or writing.
**Swagger evidence**: `viewed_files` appears zero times in `templates/swagger/v1_json.tmpl`.
**Parity impact**: server-side state that only one client can touch.
A reviewer working partly through an API client and partly in the browser sees the two disagree about which files they have already read, and review progress cannot be reported by any external tool.
**Patch sketch**: add `GET` and `PUT /repos/{owner}/{repo}/pulls/{index}/viewed-files`, the `PUT` taking a `{path: viewed-state}` map exactly as the web handler does today.
The model calls are already isolated in the web handler and lift cleanly.
**Prior art**: open issue [#32898](https://github.com/go-gitea/gitea/issues/32898), "expansions to the pull review API" (December 2024), which asks verbatim for "POST endpoints to mark files as viewed/unviewed" as part of a broader review-API wishlist — also covering PATCH review, PATCH/DELETE review comments, and reply-to-review.
The author offered to submit PRs; none materialised.
That issue is the natural home for finding 2 as well, and commenting on it may be more productive than opening two fresh issues.
Several open UI-side requests exist around viewed-files ([#32267](https://github.com/go-gitea/gitea/issues/32267), [#35401](https://github.com/go-gitea/gitea/issues/35401)) but none has an API dimension.
---
### 9. Label-set initialization is web-only
**Class**: missing route.
**Web**: `POST /{owner}/{repo}/labels/initialize` → `InitializeLabels` (`routers/web/repo/issue_label.go`), registered at `routers/web/web.go:1397`.
**Capability reached**: `repo_module.InitializeLabels(ctx, repoID, labelTemplate, isOrg)` (`modules/repository/init.go:121`), which applies a named label template such as Default or Advanced.
**Current API**: narrower than it first appears, and the framing matters.
Two thirds of this capability already exist: `GET /label/templates` and `GET /label/templates/{name}` enumerate the shipped sets (`routers/api/v1/api.go:1039-1040`), and `CreateRepoOption.IssueLabels` (`modules/structs/repo.go:151`, "Label-Set to use") applies one at repository *creation* time.
What is missing is applying a template set to an **already-existing** repository — the web's `/labels/initialize` action.
**Swagger evidence**: `labels/initialize` appears zero times in `templates/swagger/v1_json.tmpl`, while the template-read routes are present.
**Parity impact**: real but modest, and the weakest finding here.
Automation that adopts an existing repository — or re-standardises labels across many repositories after the fact — must read the template via the API it already has, then create each label with an individual call, reimplementing a loop Gitea performs server-side.
**Patch sketch**: add `POST /repos/{owner}/{repo}/labels/initialize` taking `{"template_name": "Default"}` and calling `repo_module.InitializeLabels` (`modules/repository/init.go:121`) directly.
No new discovery endpoint is needed — `GET /label/templates` already covers it.
The org-label equivalent could take the same treatment in the same patch, since the function already carries an `isOrg` flag.
**Prior art**: none for the apply-to-existing-repo action.
Merged PR [#24602](https://github.com/go-gitea/gitea/pull/24602) (May 2023) added the read-only template endpoints, and merged PR [#6061](https://github.com/go-gitea/gitea/pull/6061) added the creation-time option — cite both rather than claiming the capability is absent, or the finding will be dismissed on its first sentence.
---
## Appendix: ready-to-paste issue drafts
Drafts for the five findings at the top of the *filing* order rather than the severity ranking — those are the ones to open first.
Findings 6 and 8 are deliberately absent: both should be comments on existing issues (#6454 and #32898), not new ones.
Re-verify the commit reference before pasting if upstream has moved.
### Draft — finding 4 (time estimate)
> **Title**: API can read `time_estimate` but cannot set it
>
> The issue API exposes `time_estimate` in its responses — added in #35475, which also wired it into webhooks — but there is no way to set it through the API.
> `EditIssueOption` has no time-estimate field and no dedicated route exists.
>
> The web UI does this via `POST /{owner}/{repo}/{type}/{index}/time_estimate` (`UpdateIssueTimeEstimate` in `routers/web/repo/issue_timetrack.go`), which calls `issue_service.ChangeTimeEstimate` (`services/issue/issue.go`).
>
> The practical effect is that round-tripping an issue through the API silently drops its estimate, and any planning or import tool has to leave estimates empty. #35475 appears to have implemented the read half of this field; I would like to complete it.
>
> Proposal: add `TimeEstimate *int64` to `EditIssueOption` and call the existing service function from `EditIssue`, matching how `deadline` and `ref` are already handled. Happy to submit a PR.
### Draft — finding 7 (review comment attachments)
> **Title**: API cannot attach files to pull request review comments
>
> #29220 added attachment support for code review comments and gave `pull_service.CreateCodeComment` an `attachments []string` parameter, which the web form passes through.
> The API never does: `CreatePullReviewComment` in `modules/structs/pull_review.go` has only `Path`, `Body`, `OldLineNum` and `NewLineNum`, so API-created review comments always pass a nil attachment list.
>
> This is inconsistent with the rest of the API, which supports attachments on issues and issue comments through the `/assets` routes.
>
> Proposal: add an `Attachments []string` field to `CreatePullReviewComment` and to `CreatePullReviewCommentReplyOptions`, passed through in `CreatePullReview` and `CreatePullReviewCommentReply`. The upload route already exists; this only carries the resulting UUIDs. Happy to submit a PR.
### Draft — finding 3 (`invalidated` on review comments)
> **Title**: `PullReviewComment` does not expose whether a comment is outdated
>
> `issues_model.Comment` carries an `Invalidated` field, set when the line a review comment refers to no longer exists in the diff. The web UI uses it to hide outdated comments behind a toggle.
> The API's `PullReviewComment` struct does not expose it, so consumers listing review comments cannot distinguish live feedback from comments stranded by a subsequent push.
>
> Relatedly, the struct has no explicit `resolved` boolean — `resolver != nil` is currently the only way to infer resolution, which #15167 introduced without a corresponding flag.
>
> Proposal: add `invalidated` (and optionally `resolved`) to `PullReviewComment`, populated in `ToPullReviewComment` in `services/convert/pull_review.go` alongside the existing `Resolver` assignment. Happy to submit a PR.
### Draft — finding 1 (merge blockers) — post as a comment on #13879
> This is still open and still reproduces on current main.
>
> The API returns a single `mergeable` boolean with no reason attached. The web UI's merge box computes considerably more: `pullMergeBoxData` in `routers/web/repo/pull.go` carries `isBlockedByApprovals`, `isBlockedByRejection`, `isBlockedByOfficialReviewRequests`, `isBlockedByOutdatedBranch` and `isBlockedByChangedProtectedFiles`, plus status-check and required-signing state. All of it is reachable only from `ViewPullMergeBox`.
>
> The consequence for any merge-automating consumer — CI bots, merge queues, dashboards — is that it can see *that* a PR is unmergeable but never *why*, so it must either re-implement branch-protection logic client-side against several other endpoints or fail with no explanation.
>
> Would maintainers accept a `GET /repos/{owner}/{repo}/pulls/{index}/merge_status` returning the structured blockers? That would need the blocker computation extracted from the web layer into `services/pull` first, leaving `ViewPullMergeBox` a thin caller. Adding the fields to `PullRequest` directly seems worse, since it would cost the computation on every list read.
### Draft — finding 2 (pending review accumulation) — post on #32898 or as a new issue
> The API cannot add a code comment to an existing pending review.
>
> `POST /repos/{owner}/{repo}/pulls/{index}/reviews` creates its comments and then calls `pull_service.SubmitReview` in the same request, so the entire review must be assembled in one request body. The web UI does not work this way: `POST /{owner}/{repo}/pulls/{index}/files/reviews/comments` with `single_review=false` accumulates comments into the doer's open pending review, submitted later.
>
> To be clear about what this is *not*: #36683 added replies to already-posted comments and explicitly skips creating pending reviews, and #36903 was a different design for the same threading problem. Neither addresses accumulating a draft review.
>
> The effect is that a client walking a diff file by file — how both humans and automated reviewers work — must buffer every comment in its own memory, with no server-side draft to recover if the final submit fails.
>
> Proposal: `POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments`, validating that the review belongs to the doer and is pending, then calling `pull_service.CreateCodeComment` with `pendingReview=true`. The service function already accepts every argument needed; this is routing and validation only.

View File

@@ -39,7 +39,7 @@ It ships as both an installable npm CLI and a bundled Agent Skill, so any agent
### Language and Runtime
TypeScript on Node 20+, matching the `gh-axi` reference implementation.
TypeScript on the supported Node long-term-support majors — currently 22 and 24, as declared in the manifest's engine range and matrixed over by continuous integration.
ESM module format.
### Implementation Strategy
@@ -491,7 +491,7 @@ The dashboard's empty states are `prs: 0 open` / `issues: 0 open` (raw strings,
Empty output is never silent.
**Principle 6 — Structured errors, exit codes, idempotent mutations, no prompts.**
Errors are represented as a typed `AxiError` with one of ten named codes: `REPO_NOT_FOUND`, `ISSUE_NOT_FOUND`, `PR_NOT_FOUND`, `AUTH_REQUIRED`, `FORBIDDEN`, `RATE_LIMITED`, `TEA_NOT_INSTALLED`, `VALIDATION_ERROR`, `GIT_ERROR`, `UNKNOWN`.
Errors are represented as a typed `AxiError` with one of eleven named codes: `REPO_NOT_FOUND`, `ISSUE_NOT_FOUND`, `PR_NOT_FOUND`, `AUTH_REQUIRED`, `FORBIDDEN`, `RATE_LIMITED`, `TEA_NOT_INSTALLED`, `VALIDATION_ERROR`, `GIT_ERROR`, `TARGET_NOT_WRITABLE`, `UNKNOWN`.
The `ISSUE_NOT_FOUND`/`PR_NOT_FOUND` split (vs gh-axi's single `NOT_FOUND`) is a deliberate divergence enabled by path-based 404 classification.
API error responses are classified by HTTP status code and calling context:
@@ -516,6 +516,8 @@ tea has logins but none match the detected hostname → `REPO_NOT_FOUND` (the re
HTTP 401 from the API → `AUTH_REQUIRED` (token invalid or revoked), per the status table.
A `--login` value naming a nonexistent profile is `VALIDATION_ERROR`, listing the available profile names.
`GIT_ERROR` classifies non-zero git subprocess exits (currently only `pr checkout`), carrying git's first stderr line.
`TARGET_NOT_WRITABLE` classifies a `setup` target the filesystem refuses (`EACCES`, `EPERM`, `EROFS`), naming the file and pointing at the general remedy: it appears to be managed by another tool, so the skill or hook belongs in that tool's configuration.
It never names or infers a particular configuration manager — read-only is not diagnostic of one.
Error output is TOON-encoded to stdout (not stderr): `error: <message>`, `code: <CODE>`, and optionally `help[N]:` with suggestion lines.
The suggestions field is named `help`, not `hint`.
Exit codes: 0 success, 1 error, 2 for `VALIDATION_ERROR` — covering unknown flags, missing required inputs, and server-side 422 rejections alike (the `axi-sdk-js` `exitCodeForError` mapping; see ADR 0004).

View File

@@ -0,0 +1,151 @@
## Problem Statement
The operator runs a NixOS flake that builds every host they own, and wants gitea-axi installed the way every other tool on the host is: its own [[home-manager module]] with its own enable flag, a standalone CLI alongside the other one-module-per-tool entries.
The agent context — the [[Agent Skill]] and the [[SessionStart hook]] — is a separate concern that should follow only when a harness is present, because gitea-axi is a working CLI without a harness and coupling it to Claude Code would turn adopting a different harness later into an unrelated dependency's problem.
The module as first shipped (task 0045, ADR 0020) does not allow that shape without steering it against its grain.
Enabling the Skill or the hook without `programs.claude-code.enable` is an assertion failure, and both toggles default on, so the common case — an operator who wants the CLI and does not use Claude Code — is a build failure that tells them to turn off two toggles.
Reaching the wanted shape means setting the module's `enable` to whatever Claude Code's `enable` is, and then setting `package = null` so the module does not also install a binary the operator is installing itself.
That leaves an option named `enable` that does not mean "gitea-axi is enabled" and a `null` package that does not mean "no package".
There is a second, quieter problem underneath.
The Skill is declared by contributing an attribute to `programs.claude-code.skills`, whose type accepts either an attribute set of skills or a single path standing for a whole skills directory.
An operator who uses the path form cannot have the module's Skill merged in: the two are different branches of the option's type and the module system cannot merge them, so the configuration fails to evaluate.
The module documents this as a limitation and offers a workaround — disable the Skill and place it by hand — which is exactly the hand-copying-that-drifts the bundled Skill exists to prevent.
## Solution
Reshape the module so that enabling gitea-axi means "install the CLI, always", and the agent context follows the harness that is present.
`programs.gitea-axi.enable` installs the binary unconditionally.
A single per-harness integration toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting on, declares both the Skill and the hook for Claude Code.
Those two artefacts land only when `programs.claude-code.enable` is also on; when it is off they are silently absent, with no assertion, matching how home-manager's own `enableBashIntegration`-style toggles behave against a disabled shell.
An operator who enables gitea-axi on a host without Claude Code gets the CLI and nothing else, which is the honest outcome and today's build failure.
The Skill is declared by writing it through home-manager's file mechanism directly, into Claude Code's skills directory, rather than by contributing to `programs.claude-code.skills`.
This composes with both forms of an operator's own skills option — the attribute-set form and the whole-directory path form — because it never touches that option's type, so the collision disappears at its root rather than being escaped by a toggle.
Because the Skill now rides the module's own file declaration rather than Claude Code's options, it no longer inherits Claude Code's own enable-gate for free, so the module gates the Skill write on `programs.claude-code.enable` explicitly.
The hook continues to be declared through Claude Code's settings option and continues to inherit that module's gate.
A home-manager flake input is added so that `nix flake check` can evaluate the module against real home-manager and prove the composition, replacing the previous position that the module's wiring was verified only by a maintainer rebuild.
## User Stories
1. As an operator, I want `programs.gitea-axi.enable = true` to install the CLI whether or not I use Claude Code, so that enabling a standalone tool does not require me to also run a harness.
2. As an operator without Claude Code, I want enabling gitea-axi to succeed and simply not install any agent context, so that I am not met with a build failure telling me to turn off toggles for a harness I never asked for.
3. As an operator with Claude Code, I want the Skill and the SessionStart hook to appear automatically when I enable gitea-axi, so that the ambient context follows the harness I have without my wiring each piece.
4. As an operator who wants the CLI but manages the agent context myself, I want to turn off the Claude Code integration with a single toggle, so that I keep the binary declaratively while writing the context by hand or with `setup`.
5. As an operator who already declares my own Claude Code skills as a whole directory, I want gitea-axi's Skill to install alongside them, so that adopting the module does not force me to restructure how I manage skills or hand-copy the Skill.
6. As an operator who already declares my own SessionStart hooks, I want gitea-axi's hook merged into mine rather than replacing them, so that composing the module with my configuration adds to it instead of colliding.
7. As an operator installing the binary another way, I want `package = null` to still declare the Skill and hook from the default build, so that a system-wide install keeps the declarative context.
8. As the maintainer, I want the module's composition proven by `nix flake check`, so that a change in home-manager or the Claude Code module that breaks the way the Skill is declared fails a check rather than a rebuild weeks later.
9. As the maintainer, I want a single per-harness toggle whose shape generalises, so that adding Codex or OpenCode later is a sibling toggle rather than a reshaping of the option surface.
## Implementation Decisions
### Option surface
The module keeps `programs.gitea-axi.enable` (install the binary) and `programs.gitea-axi.package` (the package to install, or `null` to declare the context without installing the binary), both unchanged in meaning.
The two per-artefact toggles the first version carried — one for the Skill, one for the hook — are removed and replaced by one per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting to `true`.
Granularity is per harness, not per artefact: the two Claude Code artefacts move together under one switch.
The name follows home-manager's own `enableBashIntegration` convention rather than a nested attribute set, so future harnesses read as `enableCodexIntegration` and `enableOpenCodeIntegration` siblings.
Removing the old toggles is free because the module is unreleased — it landed in a single commit and has no consumers to break.
The default is a literal `true`, not a value derived from `programs.claude-code.enable`.
A config-derived default was considered and rejected: it makes the option's declared value depend on a sibling module's config, and the honest gating is better expressed where the artefacts are declared than by shifting the option's default.
There is no central aggregator toggle over all harnesses yet.
It is the analogue of home-manager's `home.shell.enableShellIntegration` and earns its place only once a second harness exists to aggregate over; it can be added additively then, at which point the per-harness toggles change their default from literal `true` to the aggregator without a rename.
### How each artefact is declared
The hook is declared through the Claude Code module's own settings option, unchanged from ADR 0020.
That module owns the settings file wholesale, so writing it directly would collide; declaring through its option lets home-manager's merge semantics compose the module's hook with an operator's own.
This leg of ADR 0020 is sound and is kept.
The Skill is declared by writing it through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, sourced from the package's published Skill.
It is no longer contributed to `programs.claude-code.skills`.
This is the change that fixes the path-form collision: the Skill write is an ordinary file declaration at a distinct path, not a contribution to the skills option's type, so it composes with both the attribute-set and whole-directory forms of that option.
The composition with the whole-directory form depends on the Claude Code module installing a path-form skills directory recursively — that is, as individually-linked files under a real directory, rather than as one symlink at the skills directory itself.
A recursive install leaves the skills directory a real directory into which the module's own nested Skill entry drops as a sibling; a non-recursive one would claim the whole directory as a single link and collide with any nested entry.
The Claude Code module installs recursively today, so the composition holds, and this is a coupling to that module's behaviour that the check below guards.
### Gating and evaluation cost
Because the Skill is declared by the module's own file mechanism rather than through the Claude Code module's options, it does not inherit that module's `enable`-gate.
The module therefore gates the Skill write on `programs.claude-code.enable` itself, in addition to `programs.gitea-axi.enable` and the integration toggle.
Without that gate the Skill would install on a host that has no Claude Code, contradicting the intended semantics, and would additionally force the package to be realised during evaluation on every host — the file mechanism reads the Skill's source path while evaluating, so an ungated declaration pays that realisation cost even where nothing consumes the Skill.
Gating on the sibling's `enable` keeps the realisation lazy: on a host without Claude Code the Skill's source is never read, so nothing is realised.
The hook needs no such explicit gate, because it is declared through the Claude Code module's option and that module already drops and never forces the declaration when it is disabled.
The two artefacts are therefore gated by different mechanisms internally — the Skill by an explicit sibling-enable condition, the hook by the sibling module's own gate — and the module comments the asymmetry.
### `package = null`
The `package = null` path is unchanged in intent.
It declares the Skill and hook from the default build without adding the binary to the operator's packages, for an operator who installs the binary another way, such as a system-wide package set.
The module reads the Skill and hook out of the operator-supplied package when one is given and out of the default build otherwise; that fallback is kept.
Its cost is narrow and was accepted: it is a second evaluation of the same derivation for one path, which reduces to a single build when the operator's system-wide install comes from the same package set, and under the new gating it is reached only when Claude Code is enabled.
### Flake input
A home-manager input is added to the flake, with its own nixpkgs following the flake's nixpkgs, so the module is checked against the same nixpkgs-and-home-manager pairing a consumer following this flake would get.
This reverses ADR 0020's position that the flake takes no home-manager input and the module is verified only by a maintainer rebuild.
The reversal is justified by the check below, which is the first automated verification of the module's composition — a thing a rebuild proves only after the fact and only on the maintainer's own configuration.
## Testing Decisions
A good test here asserts which files an operator's generation contains after enabling the module in a given configuration — that the Skill lands at its expected place alongside whatever skills the operator already declares, that the hook merges into the operator's own hooks, and that nothing lands when Claude Code is off.
It does not assert module internals: not option values, not the store paths involved, not the shape of the generated file mechanism, because those are implementation detail of the wiring and would have to change in lockstep with it.
### The seam
There is one new seam, at the highest point available: a flake check that evaluates the actual module through home-manager's standalone configuration entry point and inspects the resulting home files derivation.
Evaluating the real module against real home-manager is the highest seam because it exercises exactly what a consumer's rebuild would, short of a full system, and it is the only seam that can reach the module at all — the existing fast tier and installed-binary tier operate on the built CLI and never evaluate Nix modules.
The check builds the home files derivation under several configurations and asserts on the tree it produces:
- An operator declaring their own skills as an attribute set: the module's Skill lands alongside the operator's, each at its own name.
- An operator declaring their own skills as a whole directory: the module's Skill lands alongside the operator's directory contents.
This is the case that guards the recursive-install coupling; a regression in how the Claude Code module installs a path-form skills directory fails here as a build-time file collision.
- Claude Code disabled: no gitea-axi Skill entry is written.
This guards the explicit sibling-enable gate, which is a correctness risk this design introduces rather than one it inherits.
- The hook merges into an operator's own SessionStart hook list rather than replacing it.
Building the home files derivation is sufficient and does not require the Claude Code binary or a running agent; it is the file-linkage layer of home-manager, which is what actually decides whether two declarations collide.
The byte-identical-generation property — that importing the module without enabling it yields the same generation as never importing it — was considered as a check and rejected as too brittle to assert: home-manager churns generation internals across versions, so the assertion would fail on version drift that is not the module's bug.
The narrower disabled-Claude-Code assertion above captures the part of that property which is actually the module's contract.
### Prior art
The flake already exposes a checks output and the repository already treats `nix flake check` as the health-check command; this adds a module check alongside the existing package check rather than introducing a new kind of verification surface.
The composition assertions mirror the shape of the design-time probes that established the approach: build the home files derivation under a given configuration, then read the skills subtree to confirm which entries are present.
## Out of Scope
Codex and OpenCode declarative integration.
The option surface is shaped to accept sibling toggles for them, but no such toggle is added now, with no second harness module to test against and the imperative `setup hooks` path still available for those harnesses on a declarative host.
A central aggregator toggle over all harnesses.
It is deferred until a second harness exists, and is additive when it arrives.
Any change to the imperative install path.
`setup` and `setup hooks` are unchanged and remain fully supported; the two paths are alternatives, not stages.
Removing or reworking the `package = null` fallback.
It is kept as-is; the upstream suggestion to revisit it was examined and found not to apply under the new gating.
## Further Notes
This spec refines the home-manager module portion of the [nix-flake-packaging spec](nix-flake-packaging.md) and reverses specific decisions of [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md); the reasoning is recorded in [ADR 0021](../adr/0021-per-harness-integration-and-home-file-skill.md).
The legs of ADR 0020 that survive — the hook declared through the Claude Code settings option, the module as a content-free wiring layer, the `hook specification` as the hook's single committed source of truth, and the absence of a declarative counterpart for Codex and OpenCode — are unchanged.
The upstream consumer's own analysis proposed defaulting the context toggles to `programs.claude-code.enable`.
That proposal is not adopted: it keys an option's default off sibling config, which the operator disliked and which does not generalise cleanly to multiple harnesses, and its stated diagnosis — that the whole config block sitting inside the module's own `enable` gate is what forces the awkward wiring — was incorrect.
The forcing was the assertion, and removing the assertion while reshaping the toggles addresses the friction the analysis identified without the config-derived default.
INSTALL.md's options table, its `package = null` paragraph, and its paragraph documenting the path-form skills limitation are updated to match: the limitation paragraph is removed, because the limitation no longer exists.

View File

@@ -0,0 +1,244 @@
## Problem Statement
The maintainer runs NixOS and wants gitea-axi installed declaratively through the system configuration, alongside every other tool on the machine.
Today there is no declarative path.
gitea-axi is distributed only as an npm package, and nothing has been published yet — there are no release tags and no tarball on the registry.
Installing it means an imperative global npm install, which sits outside the system configuration, is invisible to rollbacks, and drifts from the rest of the machine's declarative state.
The repository also has no declarative development environment.
It carries no Nix expression, no direnv configuration, and no Node version file, while documenting a build, a live end-to-end tier, and a benchmark harness that all assume a Node toolchain the repository never specifies.
The continuous integration workflow pins Node 20, which reached end-of-life in April 2026, so the one place a Node version *is* named now names an unsupported one.
## Solution
A Nix flake at the repository root that exposes gitea-axi as a package, so the maintainer's NixOS configuration can add it to the system package set the same way it adds anything else.
The flake also exposes a development shell carrying the toolchain the repository actually needs — Node, `git`, and `tea` — giving `nix develop` a declarative answer to "what do I need to work on this".
A checks output makes `nix flake check` build the package and run its tests, so the flake itself is verifiable rather than silently rotting.
Because gitea-axi discovers credentials by shelling out to `tea`, and drives repositories by shelling out to `git`, the installed binary is wrapped so both are reachable from the Nix closure.
The wrapper defers to the operator's own binaries when present, and supplies the closure's only as a fallback.
Alongside the flake, the continuous integration workflow moves off end-of-life Node, tests the full range of Node versions the package claims to support, and gains the two test tiers it currently never runs.
## User Stories
1. As the maintainer, I want gitea-axi available as a flake package, so that I can install it from my NixOS configuration instead of through an imperative global npm install.
2. As the maintainer, I want the installed binary to find `git` and `tea` without my having to install them separately, so that the tool works on a fresh machine with no manual setup.
3. As the maintainer, I want my own `tea` to be the one gitea-axi invokes when I have one, so that a single `tea` version owns the credential store it reads and writes.
4. As the maintainer, I want the flake to build without my maintaining a dependency hash, so that bumping an npm dependency does not also require an edit to the Nix expression.
5. As the maintainer, I want the package version taken from the package manifest, so that a released version and its store path can never disagree.
6. As the maintainer, I want the Nix build to run the fast test tier, so that a package that builds is also a package whose behavior was checked.
7. As the maintainer, I want the Nix build to drive the binary it just installed, so that a broken executable bit or a misplaced bundled Agent Skill fails the build instead of failing on first use.
8. As the maintainer, I want the build to depend only on files that can change its output, so that writing an ADR or landing a benchmark result does not trigger a rebuild and a full test run.
9. As the maintainer, I want a development shell with Node, `git`, and `tea`, so that `nix develop` gives me the toolchain for the build, the live end-to-end tier, and the benchmark harness.
10. As the maintainer, I want the development shell and the packaged binary to share one Node reference, so that development and the shipped artifact cannot drift onto different major versions.
11. As the maintainer, I want `nix flake check` to actually build and test, so that the conventional health-check command is not a silent no-op.
12. As the maintainer, I want continuous integration to build the flake, so that I learn a build-relevant file was omitted from the source filter at the commit that caused it rather than weeks later at my next system rebuild.
13. As the maintainer, I want continuous integration to run on supported Node versions only, so that I am not gating merges on a runtime that receives no security fixes.
14. As the maintainer, I want the declared engine range to match the versions actually tested, so that the compatibility promise in the manifest is verified rather than asserted.
15. As the maintainer, I want the benchmark harness tier to run in continuous integration, so that harness logic is guarded rather than relying on my remembering its non-default runner configuration.
16. As the maintainer, I want the packaging tier to run in continuous integration, so that a broken distribution artifact is caught before a manual publish rather than by whoever installs it.
17. As the maintainer, I want the assertions about an installed binary to be written once and driven by both the npm install path and the Nix install path, so that the two distribution methods cannot drift apart in what they guarantee.
## Implementation Decisions
### Flake surface
The flake exposes a package, a development shell, a checks output, and a home-manager module.
It deliberately exposes no NixOS module and no overlay.
A NixOS module was rejected because gitea-axi is a stateless CLI with no daemon and no system-level configuration; a module would wrap the system package set and nothing else.
An overlay was rejected as an interface with no consumer — the maintainer is the sole consumer and already knows the package goes into the system package set.
Both remain purely additive to add later.
A home-manager module was initially deferred rather than rejected, on the grounds that a module managing the Agent Skill declaratively would reintroduce the automatism ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script, and that the trade-off deserved its own decision made with usage evidence.
That evidence arrived and reversed the deferral; the flake exposes `homeModules.gitea-axi` as of task 0045.
The reasoning is recorded in [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md).
In short: `setup` and `setup hooks` are write-only against files the operator is assumed to own, so an operator whose agent configuration is generated cannot use them at all and hand-copies the Skill instead, where it drifts from the package shipping it.
And a module the operator imports and then explicitly enables is not the automatism ADR 0009 guards against — that guard is against installation without intent, and an `enable` option is intent.
Consumers deduplicate nixpkgs by pointing the flake's nixpkgs input at their own.
Consequently the flake's own nixpkgs input governs only standalone builds, the development shell, and flake checks — never the deployed artifact.
That input tracks the unstable channel, matching the maintainer's system, so the development shell reflects the same package set the installed binary is built against.
System coverage is the four common Linux and Darwin targets, enumerated with a small helper built from the nixpkgs standard library rather than by taking a dependency on a third-party systems-enumeration flake.
Cross-platform support is close to free because the package contains no compiled code; the only per-system variation is which Node, `git`, and `tea` are pulled in.
### Package expression
The derivation lives in its own expression, separate from the flake, written in the conventional callable form that nixpkgs uses.
This keeps the flake's own file to interface concerns — what it consumes and what it exports — and leaves the derivation buildable outside a flake context, usable in an overlay unchanged, and upstreamable to nixpkgs later.
Dependencies are fetched by deriving each package's fetch from the integrity fields already present in the lockfile, rather than by a single fixed-output derivation keyed on a hash committed to the Nix expression.
The hash-based approach was rejected on maintenance grounds: it breaks on every lockfile change and is repaired by copying a hash out of an error message into the expression, which is a permanent recurring tax and a standing source of stale-hash commits.
The lockfile-derived approach is viable here specifically because every runtime dependency resolves to the public npm registry with an integrity field, there are no git or filesystem dependencies, and the lockfile is version 3.
The package version is read from the package manifest at evaluation time.
The manifest is already the canonical version — the documented release flow bumps it — and hardcoding it in the Nix expression would create a second place to update, whose omission yields a store path labelled with one version containing another's code.
The runtime Node is the nixpkgs default, currently a supported long-term-support release.
Node 20 is not an option: nixpkgs marks it with known vulnerabilities as end-of-life, so using it would require the consuming configuration to permit an insecure package.
The development shell references the same Node attribute as the package, so the two cannot drift.
### Source filtering
The derivation's source is an explicit allowlist of the paths the build and its tests actually read: the TypeScript sources, the test tier, the bundled Agent Skill, the package manifest and lockfile, the two TypeScript configurations, and the default test-runner configuration.
Taking the whole repository was rejected because this repository's highest-churn directories are all build-irrelevant — the ADR, spec, and task directories, the benchmark harness, and the prose documentation.
Under a whole-repository source, writing an ADR invalidates the derivation and forces a full rebuild including the test suite.
A gitignore-derived filter was rejected as insufficient for the same reason: it still admits the benchmark harness, the agent-context directory, and the several additional test-runner configurations.
The cost is that adding a build-relevant top-level file requires updating the allowlist.
That failure is loud and immediate — the build fails on a missing file — but it is disconnected enough from its cause to warrant both a Gotcha entry in the agent instructions and the continuous-integration flake job described below.
### Runtime dependency wrapping
The installed binary is wrapped so that the closure's `git` and `tea` are appended to the operator's existing search path, not prepended.
The operator's own binaries win where present; the closure supplies a fallback so a fresh machine works and the not-installed error path becomes unreachable in practice.
This is the reverse of the hermetic instinct, and the reason is specific to `tea`.
Per ADR 0001 as amended, the token is fetched through `tea`'s git-credential-protocol interface, which refreshes near-expiry OAuth tokens *in place*.
The invoked `tea` therefore writes to the operator's credential store, so prepending would mean two `tea` versions mutating one state file — the maintainer's for interactive login management, the closure's for token refresh.
`git` takes the same treatment for uniformity, after the reproducibility argument for prepending it was examined and rejected as illusory: a pinned `git` still reads the operator's global configuration, so behavior is not actually pinned, while a closure `git` lacking an extension the operator relies on — a filter, a credential helper — would *introduce* the divergence that prepending is meant to prevent.
Replacing the search path outright was rejected for the same reasons in stronger form, plus it would break repository operations over SSH remotes.
`tea` remains a runtime dependency for credential discovery only.
ADR 0002 moved command dispatch to the Gitea REST API but explicitly retained `tea` for auth, and nothing in this work changes that.
### Verification inside the build
The build runs the fast test tier, which requires a real `git` and a `which` in the check inputs because two of its files invoke `git` directly and one resolves `git` by lookup; `tea` is already stubbed within that tier.
The live end-to-end tier and the benchmark smoke tier are excluded because they require a live Gitea host.
After installation, the build drives the wrapped binary through the shared installed-binary tier described under Testing Decisions.
This guards a class of failure the fast tier structurally cannot reach: the compiler does not set the executable bit that npm would otherwise set from the manifest's `bin` entry at install time, and the `setup` command resolves the bundled Agent Skill relative to its own module location, which makes the relative arrangement of the built output and the bundled Skill load-bearing.
The checks output aliases the package, so the conventional flake health-check command builds it and thereby runs both phases.
Granular per-stage checks were rejected: the one stage that would add real coverage is the full typecheck, which spans the test and benchmark directories and would therefore drag the benchmark harness into the derivation's inputs, undoing the source filtering above.
The full typecheck stays in continuous integration, where it already runs.
### Continuous integration
The workflow matrixes over the two supported Node versions and the declared engine range narrows to match.
The current declaration promises support down to Node 20 while testing only Node 20, so the entire claimed range below the tested version is unverified and its floor is end-of-life.
Narrowing the range is free right now because nothing has been published and no tags exist; that window closes at first publish.
The live end-to-end tier runs on the highest matrix leg only, because it exercises the Gitea API contract rather than Node-version behavior, and each leg provisions a full Gitea service.
Two tiers join the workflow.
The benchmark harness tier runs on every leg: it is deterministic, needs no network or agent SDK, and is currently unguarded despite its non-default runner configuration being an easy thing to believe is running when it is not.
The packaging tier runs on the highest leg only, being slow and largely version-independent; it is the only automated guard on the distribution artifact given that publishing is a manual command.
The benchmark smoke tier stays out of continuous integration.
It targets a live host discovered through the maintainer's own credentials and skips cleanly when they are absent, so in continuous integration it would pass by skipping — a green check that verified nothing.
A separate job builds the flake, on both push and pull request.
It is deliberately non-gating for the other jobs, so an infrastructure problem with Nix availability on the runner does not block an otherwise legitimate change.
Its value is detecting flake rot — most concretely, a build-relevant file omitted from the source allowlist — at the commit that causes it rather than at the maintainer's next system rebuild.
Its cost is honest: because the checks output aliases the package, this job re-runs the fast tier inside the derivation and, without a warm store, rebuilds the whole dependency closure.
Release automation stays out of scope.
Automating a publish path that has never once been exercised manually would encode assumptions about a process with no track record, and the artifact-integrity concern that would motivate it is already covered by adding the packaging tier.
## Testing Decisions
A good test here asserts externally observable behavior of an *installed* gitea-axi — that the binary runs, renders, and installs its Agent Skill — and not the mechanics of how it came to be installed.
Nothing should assert on store paths, wrapper script internals, derivation attribute values, or the arrangement of files within the installed tree, because all of those are implementation detail of the packaging method and would have to change in lockstep with it.
### The seam
There is one new seam, and it is a parameterization of an existing tier rather than a new suite.
The packaging tier today contains two kinds of assertion coupled only by a shared setup step: assertions about the shape of the packed tarball and its manifest, and assertions that drive the resulting installed binary.
The second group is parameterized by exactly one value — the path of the binary to drive — and is precisely what the Nix build needs to assert about its own installed output.
That group is therefore split out and taught to accept its binary path from the environment.
When the environment names an already-installed binary, the tier drives it directly and skips the pack-and-install setup; when it does not, the tier packs and installs as it does today and drives the result.
The npm distribution path and the Nix installation path become two callers of one seam.
The tarball-shape assertions remain npm-only, since a Nix installation produces no tarball and no packed manifest.
This split also improves the existing tier on its own terms, by separating two concerns that were only ever joined by an expensive shared setup.
Two alternatives were rejected.
A bespoke shell smoke test in the Nix build would need no TypeScript change and no test runner inside the derivation, but it is a second and weaker seam asserting the same intent, and it would not have caught the bundled-Skill arrangement bug that motivates the check at all.
Running only the fast tier after installation was rejected because it does not exercise the installed layout, which is the entire class of failure the post-install check exists to catch.
### Consequential change to existing assertions
The packaging tier currently asserts that the declared engine range mentions Node 20.
Narrowing the engine range changes that assertion.
It is part of this work rather than a later surprise.
### Prior art
The installed-binary assertions already exist and are the model: they drive a real subprocess, answer its HTTP calls with the in-process fixture server used throughout the suite, and point the `setup` command at a temporary home directory to observe the Agent Skill being written.
Unlike the in-process CLI-seam harness, which keeps the environment fully explicit, this tier deliberately inherits the parent environment because the spawned binary genuinely needs it — that remains true, and is more true under a wrapper.
### Not covered by automated tests
The flake's consumption from a NixOS configuration is verified by the maintainer performing a system rebuild, not by an automated test.
Building the package proves the derivation is correct; whether the maintainer's configuration wires it in correctly is outside this repository.
## Out of Scope
Removing the `tea` runtime dependency.
It was raised and examined during design: ADR 0002 moved command dispatch off `tea` but retained it for credential discovery, and eliminating it would mean either owning a credential store or reading `tea`'s internal configuration format, which ADR 0001 explicitly rejected because it forfeits OAuth token refresh.
Wrapping the binary makes the dependency invisible in practice, which removes most of the practical motivation.
An overlay output and a NixOS module output.
A direnv configuration.
The maintainer does not currently run direnv, so committing one would be configuration for a tool that is not installed.
Release and publish automation, and the first publish itself.
Migrating continuous integration to build via Nix.
The workflow keeps its container-and-npm shape, deliberately preserving the GitHub Actions compatibility the workflow documents as a goal; the Nix job is additive.
## Further Notes
### 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.
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 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 first mitigation was documentation: the `setup` command's help text stated that `setup hooks` must be re-run after an upgrade.
The failure it guarded 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.
**Task 0043 superseded that mitigation and this section's conclusion.**
The preference for the bare name is reachable through a wrapper-based install after all; it was only being reached for from the wrong end.
`setup hooks` now resolves `gitea-axi` on `PATH` the way a shell does and hands *that* location to the SDK, so the realpath comparison succeeds and the bare name is recorded — under Nix as under npm.
The absolute path survives as the fallback for a binary that is not on `PATH` at all.
The reasoning is recorded in [ADR 0019](../adr/0019-hook-records-search-path-name.md); the help text's re-run instruction is gone, having become false.
The related defect on the same line went with it: `isManagedHook` recognises its own hook by testing whether the recorded command string *contains* the marker `gitea-axi`, so an entrypoint path lacking that substring made `setup hooks` append a duplicate rather than update in place.
`setup hooks` now prunes duplicates itself, recognising its entry by the exact command it records rather than by a substring of it.
That coupling was why `package.nix` renamed its build tree in `postUnpack`; the rename is deleted, and the build running green from `/build/source` is what demonstrates the coupling is gone.
### Verified during design
The `tea` in nixpkgs carries the credential-helper interface that ADR 0001's amendment depends on, under the singular alias the code uses.
All three runtime dependencies resolve to the public npm registry.
The lockfile is version 3 and the lockfile-derived fetching helper is available.
There are no native modules and no install scripts in the runtime closure.
The reserved self-update command is already shadowed and never writes, so it poses no read-only-store hazard.
### Candidate ADR
The wrapper's deference to the operator's own binaries warrants an ADR.
It is surprising without context, since the hermetic instinct points the other way; it is the product of a real trade-off between reproducibility and single-owner mutable state; and it is hard to reverse in the sense that flipping it can corrupt an operator's credential store rather than merely changing behavior.

View File

@@ -0,0 +1,88 @@
## Problem Statement
An agent doing a PR review round-trip with gitea-axi cannot complete it inside the tool.
When reading a reviewer's inline comments via `pr view --reviews`, the output gives the author, file path, and body, but not which line each comment is anchored to, nor the comment's id.
Comments like "what does this do?" or "wasn't this set earlier?" are unanswerable from the output alone, forcing a fallback to a raw Gitea API call (and scraping the token out of tea's config) just to recover the anchor.
When writing, `pr review` accepts only a single `--body` for the whole review, so "reply to each of ten inline comments" collapses into one consolidated body that restates each thread by hand, rather than a reply landing under each comment where the reviewer left it.
## Solution
Complete the read and write halves of the inline-review round-trip, both at the existing `pr` commands.
On the read side, `pr view --reviews` surfaces each inline comment's `id`, its `diff_hunk` (so the anchoring code travels with the comment), and whether the thread is already `resolved`.
On the write side, `pr review` gains a `--comments-file` flag that carries a batch of inline comments — each either a reply into an existing thread (by comment id) or a fresh comment on a new-file line — mapped onto the review-submission payload Gitea already accepts.
## User Stories
1. As a reviewing agent, I want each inline review comment's anchoring `diff_hunk` in the `--reviews` output, so that I can answer a bare "what does this do?" without a second API call.
2. As a reviewing agent, I want each inline review comment's `id` in the `--reviews` output, so that I can target that exact comment when replying.
3. As a reviewing agent, I want to see whether each inline comment's thread is already `resolved`, so that I skip settled conversations instead of re-answering them.
4. As a reviewing agent, I want the `diff_hunk` trimmed to its header line plus its last couple of lines by default, so that I get the file-line anchor and the code at the comment without paying for the whole hunk.
5. As a reviewing agent, I want `--full` to expand each `diff_hunk` to its complete text, so that I can read the entire hunk when the trimmed tail is not enough.
6. As a PR author, I want to reply to an existing inline comment by its id, so that my reply lands in that reviewer's thread without me computing any line number or side.
7. As a PR author, I want to post a fresh inline comment on a new-file line, so that I can raise a point on code no one has commented on yet.
8. As a PR author, I want to submit a batch of inline comments in one file alongside my review, so that "reply to each of ten comments" is one command, not ten.
9. As a PR author, I want gitea-axi to figure out the new-vs-old side of a reply for me, so that I never have to reason about diff sides.
10. As a PR author, I want the inline-comment batch to compose with the existing review action and optional top-level body, so that I can approve/request-changes/comment while attaching inline replies.
11. As a PR author, I want a clear validation error when a reply targets a comment id that isn't on the PR, so that a typo fails fast instead of silently posting nowhere.
## Implementation Decisions
### Read side — anchor fields on `pr view --reviews`
- The `--reviews` review rows add three fields per inline comment: `id`, `diff_hunk`, and `resolved`.
- `resolved` is `yes`/`no`, derived client-side from whether the comment's `resolver` is set (Gitea returns `resolver` as a populated user once a thread is resolved).
- The raw `position` / `original_position` diff offsets are deliberately **not** surfaced — they are diff offsets an agent cannot map to a file line without the patch, and the `diff_hunk` header already carries the human-meaningful line range.
- `diff_hunk` is rendered with a bespoke **structural trim**, not the char-based content-truncation used for bodies: by default, the hunk's `@@` header line plus its last two lines (collapsed when the hunk is three lines or fewer).
This keeps both the file-line anchor (the `@@` header) and the code at the comment (the tail), which the keep-head char truncation would get backwards by dropping the tail.
- Under `--full`, the entire `diff_hunk` is emitted verbatim, consistent with `--full` meaning "no trimming anywhere"; the hunk is never run through the body char-truncation path.
- These fields ride the existing `--reviews` fetch (reviews list plus one inline-comments fetch per review); no extra API calls are introduced.
### Write side — `--comments-file` on `pr review`
- `pr review` gains `--comments-file <path>`, a JSON array of inline-comment entries submitted as part of the review; the existing action flag (one of `--approve` / `--request-changes` / `--comment`) is still required, and top-level `--body` stays optional.
- Each array entry is one of two shapes, and there is **no `side` field anywhere**:
- Reply: `{ "reply_to": <comment-id>, "body": "..." }`.
- New comment: `{ "path": "...", "line": <new-file line>, "body": "..." }`.
- A new comment maps `line` to `new_position`; it is always the new side, because a line addressable by new-file number is by definition on the new side.
A prototype against the live host confirmed the create payload treats `new_position` as a **file line number** (not a diff offset) and that a single inline comment posts successfully this way.
- A reply carries no line or side.
gitea-axi locates the target comment (there is no get-comment-by-id endpoint, so it reuses the same reviews-plus-comments fan-out the read side already performs), reconstructs that comment's anchor from its own `diff_hunk`, and posts a matching inline comment; because Gitea threads comments by line, a same-line post joins the existing conversation, so side is inferred from the target rather than supplied.
- All entries map onto the `comments[]` array of the review-submission payload (each element a `{ path, new_position | old_position, body }`), which the SDK already accepts but gitea-axi previously left unpopulated — no new HTTP layer or endpoint is added.
- A `reply_to` id that is not found among the PR's review comments is a `VALIDATION_ERROR` raised before submission, mirroring how `pr review` already validates its action flags up front.
- Mutation output follows the established action-block/entity-block convention for `pr review`; the inline-comment count is reflected in the reported result.
### Sequencing
- The read side ships first: it is pure read, and its surfaced `id` is the handle the write side's replies target.
- The write side ships second, on top of the id exposed by the read side.
## Testing Decisions
- Both halves are tested at the single existing **fixture-server CLI seam**: a fixture server maps request path/method to recorded Gitea JSON, the built CLI is driven via the run-CLI test helper, and assertions are on rendered `stdout` and on the recorded outbound requests.
No new seam is introduced.
- Good tests here assert external behavior only — the exact rendered TOON lines and the captured request payload — never internal rendering helpers.
- Read side: drive `pr view N --reviews` (and again with `--full`) against fixture reviews and inline comments whose JSON carries `id`, `diff_hunk`, and a set/unset `resolver`; assert the rendered `id`, the trimmed-vs-full `diff_hunk`, and `resolved: yes/no`.
Prior art: the existing `pr view --reviews` test that already stubs the reviews and per-review comments endpoints and asserts the `reviews[...]` block.
- Write side: write a `--comments-file` via the existing temp-file test helper, drive `pr review N --comment --comments-file <f>` against a stubbed review-submission endpoint, and assert the **captured request body's** `comments[]` (path + `new_position`, and the reply case's reconstructed anchor) plus the action-block output.
Prior art: the existing `pr review` test that inspects the recorded POST body via the fixture server's request log, and the reply case additionally stubs the reviews-plus-comments GETs used for the lookup.
- The reply-lookup failure path is covered by asserting a `VALIDATION_ERROR` and that no submission request was made, matching the existing "rejects ... before any API call" tests.
## Out of Scope
- Resolving / unresolving review conversations (issue #39).
Gitea exposes no REST endpoint for this — verified exhaustively against the live host — and the only mechanism is a CSRF-guarded internal web route returning HTML, which a prototype confirmed rejects token auth.
Parked as blocked-upstream; the read side's `resolved` field covers only *seeing* resolution state, not changing it.
- A raw `api` passthrough / generic escape hatch (issue #40); dropped from this work.
- Surfacing raw `position` / `original_position` diff offsets as rendered fields.
- Inline repeatable comment flags (e.g. paired `--on path:line` / `--body`); the JSON `--comments-file` is the sole input shape.
- Old-side *new* comments authored by hand; old-side anchoring is reachable only through the reply path, where it is inferred from the target comment.
## Further Notes
- The prototype that validated the write-side `new_position` semantics left one non-removable residue on the live repo: a closed throwaway PR (#41) carrying one review comment, since Gitea cannot hard-delete PRs.
- The "no REST resolve endpoint; web-route-only and CSRF-guarded" finding for the parked #39 is recorded so it is not re-derived.
- Keeping gitea-js as the sole HTTP layer is preserved: dropping the passthrough and confining resolve to out-of-scope means no raw-request path is introduced by this work.

View File

@@ -0,0 +1,78 @@
# Read-tier accuracy
## Problem Statement
The benchmark ranks gitea-axi first on both cost metrics — lowest cost-equivalent tokens and lowest imputed cost of the four arms — but second on accuracy, 95% against gitea-mcp's 97%.
That entire two-point deficit is a single task: `read-open-issue-count`, which gitea-axi fails on all three trials while gitea-mcp passes one of three.
The read tier is the weakest tier for every arm, and on it gitea-axi spends more turns and more output than gitea-mcp yet scores lower, so agents are working harder to answer count questions and still getting them wrong.
Two things stand in the way of closing this gap.
First, the `issue list` summary reports `count: N of M total` and never names the state it filtered on, so an agent asking "how many issues are open?" has to infer the answer rather than read it off the summary line.
Second, the benchmark records only tokens and a `failure: "incorrect"` tag for a failed read — never the agent's actual report — so a maintainer cannot tell whether the agent reported a wrong number or reported the right number in wording the checker's phrase list did not accept.
Without the report text the root cause of the 3/3 failure cannot be confirmed, so the product fix would be a guess.
## Solution
Make list count summaries self-answering, and make read failures diagnosable.
For the product, the count line of a filtered list names the state it counted.
When a maintainer or an agent runs `issue list` on the default open filter, the summary states that the count is a count of open issues, so the answer to "how many are open?" is present in the summary rather than only inferable from each row's state field.
For the harness, every result record carries the agent's final report for read tasks.
A failed read is then diagnosable directly from the stored record: the maintainer can see the exact text the agent submitted and tell a wrong count apart from a right count phrased in words the checker did not list.
This is the prerequisite that turns the `read-open-issue-count` failure from an opaque tag into evidence, and it is the data that decides whether the checker's accepted-phrasing list is later too strict.
## User Stories
1. As an agent answering a count question, I want the list summary to state which state it counted, so that I can report "5 open issues" straight from the summary line without inferring it from the rows.
2. As a maintainer reading `issue list` output, I want the count line to say what it counted, so that a bare `count: 5` is never ambiguous about whether those five are open, closed, or all issues.
3. As a maintainer auditing a failed read trial, I want the stored result record to include the agent's final report, so that I can see exactly what the agent said instead of only that it was "incorrect".
4. As a maintainer deciding whether the read checker is too strict, I want the persisted reports across trials, so that I can judge from evidence whether correct answers are being rejected on wording rather than substance.
5. As a maintainer re-running the benchmark after the count-line change, I want `read-open-issue-count` to move from a consistent failure toward a pass, so that gitea-axi's accuracy stops trailing on the one task that accounts for the whole gap.
## Implementation Decisions
- Two modules change: the `issue list` command in the product, and the benchmark runner's result-record assembly in the harness.
They serve one goal but are testable at different seams and can land independently, with the harness change first so the product fix can be confirmed against real report text.
- **State-aware count line (product).**
The list command composes the state into its own count line; the generic render helper that formats count lines stays generic.
The command already resolves the effective state filter (defaulting to open), so it passes that filter descriptor into the summary rather than the helper learning about issue state.
This keeps a single render seam and lets each list command opt in on its own terms; `pr list`, `search`, and `dashboard` are unaffected unless they choose to opt in the same way.
The existing count-line invariants are preserved: a total is always reported, and the bare `count: N` form must never appear.
- **Report persistence (harness).**
The agent's final report is already in hand at the point the runner scores a read task, so persisting it is threading that value into the record the runner assembles rather than plumbing it up from a new source.
The report is added to the result record for read tasks; the store serializes whatever record it is handed, so it needs no change of its own.
Mutation tasks are scored by diffing repository state and have no agent report to record, so the field is populated for read tasks and absent otherwise.
- **Ordering.**
Persist the report first and re-run enough of the read tier to capture real reports, then confirm from those reports whether the failure is a wrong count or a rejected phrasing before finalizing the count-line wording.
The count-line wording should be chosen so that an agent quoting the summary lands on an answer the read checker already accepts.
## Testing Decisions
- A good test here asserts external behavior: the text a user or agent sees on stdout, and the shape of the record the harness writes — not the internal composition of the count string.
- **Count line** is tested at the command's behavioral seam — the fixture-server CLI harness that runs the real command against a stubbed Gitea and asserts on rendered stdout.
Prior art is the existing `issue list` command test, which already asserts exact count-line strings such as `count: 3 of 17 total` and guards that the bare `count: N` form never appears.
New assertions extend that file: the count line names the state for the default open filter and for an explicit state filter, and the existing total-and-cap invariants still hold.
- **Report persistence** is tested at the runner's record-assembly seam, where the existing runner test already drives a cell to a recorded outcome and asserts on the produced record.
A completed read cell yields a record carrying the agent's final report; a mutation cell yields a record without one.
## Out of Scope
- Trimming gitea-axi's input and cache-read footprint.
gitea-axi replays the largest context of the efficient arms and wins cost only on the 5×-weighted output component, so reducing its input footprint would harden the cost lead — but it is a separate optimization touching many commands and is not part of closing the accuracy gap.
- Relaxing the read checker's accepted-phrasing list.
Whether the checker is too strict is a benchmark-validity decision that must be made from the persisted report evidence this spec produces, not pre-judged; changing accepted phrasings before seeing the reports risks tuning the benchmark to the tool rather than fixing the tool.
- Any change to how mutation tasks are scored, and any change to the cost-equivalent-token metric or its weighting.
## Further Notes
The two leaders trade a narrow accuracy edge for a clear cost lead, and this one task is the whole of that edge, so the count-line change is the single highest-leverage move on the accuracy axis.
The report-persistence change also has standing value beyond this task: it makes every future read failure diagnosable rather than opaque, which is the harness's current blind spot and the reason the root cause could not be confirmed from the existing records.
The state-explicit summary is squarely on gitea-axi's central thesis — an agent-ergonomic, low-token interface — because it hands the answer to a count question directly on the summary line instead of forcing the agent into extra turns to derive it.

View File

@@ -0,0 +1,26 @@
---
spec: read-tier-accuracy
---
## What to build
Persist the agent's final report on the benchmark result record for read tasks, so a failed read is diagnosable directly from the stored record instead of only carrying an opaque `incorrect` tag.
The report is already in hand where the runner scores a read task — threading it into the assembled record is all that is required; the sample store serializes whatever record it is handed and needs no change of its own.
Mutation tasks are scored by diffing repository state and have no agent report, so the field is populated for read tasks and absent otherwise.
## Acceptance criteria
- [x] The result record carries the agent's final report for read tasks.
- [x] The field is absent on records for mutation tasks.
- [x] A completed read cell produces a record whose report is the agent's final report; a mutation cell produces a record without one — asserted at the runner's record-assembly seam.
- [x] The sample store round-trips the report-bearing record without any change to the store itself.
- [x] Existing runner and store tests still pass.
## Implementation Notes
- `ResultRecord` gained an optional `report?: string`. The runner resolves the scoring spec once in `runCell` and records `run.finalReport` only when `spec.kind === "read"`, so the field is populated for read tasks and absent otherwise. `makeRecord` conditionally spreads the key (`...(report !== undefined ? { report } : {})`) so a mutation (or hung) record genuinely omits it rather than carrying `report: undefined`; verified by `expect(sample).not.toHaveProperty("report")` after the JSON round-trip.
- `scoreRun` was refactored to take a pre-resolved `ScoringSpec` instead of a `BenchTask`. This is marginally more than "thread the value into the record," but it is the minimal clean way to branch on `spec.kind` in `runCell` without calling `task.scoringSpec(owner)` twice.
- The store needed no change, as the spec predicted: it serializes whatever record it is handed, so the round-trip test passed on first run.
- Two review findings were left as deliberate judgement calls (both non-blocking baseline smells, no documented-standard breach): the `makeRecord` positional parameter list (extended by one param in the module's pre-existing positional style, kept consistent with surrounding code rather than refactored to an options object), and the two `spec.kind` branches a few lines apart in `runCell` and `scoreRun` (they select different things — the report vs. the scorer — and read clearer inline).

View File

@@ -0,0 +1,29 @@
---
spec: read-tier-accuracy
blocked-by: 0032-bench-read-report-persistence
---
## What to build
Make the `issue list` count line name the state it filtered on, so the answer to "how many issues are open?" is present in the summary rather than only inferable from each row's state field.
The command already resolves the effective state filter (defaulting to open), so it composes that state into its own count line and passes the composed line down; the generic count-line render helper stays generic and unaware of issue state. This keeps a single render seam and lets other list commands (`pr list`, `search`, `dashboard`) opt in on their own terms rather than inheriting the behavior.
The count-line wording is chosen so that an agent quoting the summary lands on an answer the read checker already accepts (its accepted renderings include forms like `5 open`). After the change lands, a re-run of the read tier confirms `read-open-issue-count` moves from a consistent failure toward a pass — using the reports persisted by [[0032-bench-read-report-persistence]] to confirm the failure was a wrong/inferred count rather than a rejected phrasing before finalizing the wording.
## Acceptance criteria
- [x] The `issue list` count line names the state it counted for the default open filter.
- [x] The count line names the state for an explicit `--state` filter.
- [x] The generic count-line render helper is unchanged and remains state-agnostic; `pr list`, `search`, and `dashboard` output is unaffected.
- [x] Existing count-line invariants hold: a total is always reported, and the bare `count: N` form never appears.
- [x] New assertions extend the existing `issue list` command test at the fixture-server CLI seam, asserting exact rendered count-line strings.
- [-] A re-run of the read tier shows `read-open-issue-count` moving toward a pass, and the chosen wording contains a rendering the read checker already accepts.
## Implementation Notes
- The `issue list` count line now renders `count: <shown> <state> of <total> total` — e.g. `count: 5 open of 5 total`, `count: 1 closed of 1 total`. The command composes the state into the count line via a local `countStateQualifier(state)` helper; the generic `formatCountLine` gained only an optional, domain-agnostic `qualifier?: string` and never learns about issue state. Every other caller (`pr list`, `search`, `dashboard`, `label`, `issue`'s blocks/blocked-by list) passes no qualifier, so their output is byte-identical — the untouched command tests still pass, which is what criterion 3 really guards.
- **Criterion 3 wording.** Read literally, `formatCountLine` is not "unchanged" — it gained a parameter. But it stays *state-agnostic* (the qualifier is a bare string; state-to-qualifier mapping lives in the command), which is the spec's actual intent ("the generic render helper that formats count lines stays generic … rather than the helper learning about issue state"). The single render seam is preserved. Marked satisfied on that reading; the spec author may wish to reword the criterion from "unchanged" to "state-agnostic".
- **`--state all`.** Deliberately renders with no state word (`count: N of M total`): `all` imposes no narrowing filter and has no natural one-word name, so naming it adds no disambiguation. Open and closed — the filters where a bare count could mislead — are named, which is what resolves User Story 2's ambiguity. Pinned by a dedicated `--state all` test.
- **Criterion 6 (`[-]`, deferred not dropped).** The controllable half is done and verified: the chosen wording contains a checker-accepted rendering — running the real `checkReadAnswer` against the real `formatCountLine(5, 5, false, "open")` output (`count: 5 open of 5 total`) scores a pass on the `read-open-issue-count` fact, so an agent that merely echoes the summary now passes. The live read-tier re-run itself needs the benchmark environment (a live Gitea host + the Claude Agent SDK) and is left as a follow-up to run when the harness is next exercised; the report-persistence from [[0032-bench-read-report-persistence]] is now in place to confirm the movement from real report text.
- Reconciled the pre-existing count-line assertions that the format change made stale (five in `test/issue-list.test.ts`, two in `test/detection.test.ts`) to the state-named form; added a `--state all` guard test.

View File

@@ -0,0 +1,40 @@
---
spec: pr-review-comments
---
## What to build
Extend `pr view <n> --reviews` so each inline review comment carries the anchoring information an agent needs to answer and reply to it without a second API call.
Each inline-comment row gains three fields: `id` (the comment's own id, the handle replies target), `diff_hunk`, and `resolved` (`yes`/`no`, derived client-side from whether the comment's `resolver` user is populated).
`diff_hunk` renders with a bespoke **structural trim**, distinct from the char-based body truncation: by default the hunk's `@@` header line plus its last two lines, collapsed when the hunk is three lines or fewer.
This keeps both the file-line anchor (the header) and the code at the comment (the tail).
Under `--full` the entire `diff_hunk` is emitted verbatim — the hunk is never run through the body char-truncation path.
The raw `position` / `original_position` diff offsets are deliberately not surfaced.
These fields ride the existing `--reviews` fetch (reviews list plus one inline-comments fetch per review); no extra API calls are introduced.
## Acceptance criteria
- [x] Each inline-comment row under `--reviews` renders `id`, `diff_hunk`, and `resolved`
- [x] `resolved` is `yes` when the comment's `resolver` is populated and `no` otherwise
- [x] Default `diff_hunk` shows the `@@` header line plus the hunk's last two lines; a hunk of three lines or fewer renders in full
- [x] `--full` emits the entire `diff_hunk` verbatim, with no char-truncation applied to it
- [x] Raw `position` / `original_position` are not rendered
- [x] No additional API calls beyond the existing reviews-plus-per-review-comments fetch
- [x] Fixture-server tests drive `pr view N --reviews` and `--full` against reviews and inline comments carrying `id`, `diff_hunk`, and a set/unset `resolver`, asserting the rendered `id`, trimmed-vs-full `diff_hunk`, and `resolved: yes/no`
## Implementation Notes
- The structural trim lives in `src/diff.ts` as a pure `trimDiffHunk(hunk)` beside `truncateDiff`: for a hunk longer than three lines it returns the first (`@@` header) line plus the last two lines joined by newline; three lines or fewer is returned unchanged.
`buildReviewRows` in `src/commands/pr.ts` calls it for the default path and passes `diff_hunk` verbatim under `--full`, so the hunk never touches the char-based body-truncation path.
- The inline-comment row's field order is `id, author, path, resolved, diff_hunk, body`, which is the TOON table header the tests assert against.
- `resolved` is `comment.resolver ? "yes" : "no"` — a truthiness check on the SDK's optional `resolver` user, matching the spec's "set / not set" derivation.
- No fresh RED for the `resolved: yes`, `--full`-verbatim, and ≤3-line-full cases: the cohesive `trimDiffHunk` helper (and the `resolver` truthiness branch) implemented in the first GREEN already covered them, so their tests were green on arrival.
Each was proven non-vacuous with a sentinel-probe swap (per the TDD skill) and kept as a regression guard rather than dropped.
- No new API calls: the three fields ride the existing reviews-plus-per-review-comments fan-out that `--reviews` already performs.
Review follow-ups (`/review-uncommitted`), both addressed in this branch:
- Standards flagged `id: comment.id ?? 0` as fabricating an identifier — the very handle a reply copies into `reply_to`. Replaced with a `reviewCommentId` helper that throws `UNKNOWN` on a missing id, mirroring the repo's existing `pullNumber`/`headSha` "never invent an identifier" convention.
- Spec flagged the exactly-four-line trim boundary (the shortest hunk the `<= 3` guard actually trims) as untested. Added a regression test for it; a `<= 4` off-by-one would now fail.

View File

@@ -0,0 +1,43 @@
---
spec: pr-review-comments
blocked-by: 0034-pr-review-anchor-fields
---
## What to build
Give `pr review <n>` a `--comments-file <path>` flag carrying a JSON array of inline comments submitted as part of the review.
The existing action flag (exactly one of `--approve` / `--request-changes` / `--comment`) is still required, and top-level `--body` / `--body-file` stays optional, so an agent can approve/request-changes/comment while attaching inline replies.
Each array entry is one of two shapes, with no `side` field anywhere:
- New comment: `{ "path": "...", "line": <new-file line>, "body": "..." }``line` maps to `new_position`; it is always the new side, because a line addressable by new-file number is by definition on the new side.
- Reply: `{ "reply_to": <comment-id>, "body": "..." }` — carries no line or side.
gitea-axi locates the target comment (no get-comment-by-id endpoint exists, so it reuses the same reviews-plus-comments fan-out the read side performs), reconstructs that comment's anchor from its own `diff_hunk`, and posts a matching inline comment; because Gitea threads comments by line, a same-line post joins the existing conversation, so side is inferred from the target rather than supplied.
All entries map onto the `comments[]` array of the review-submission payload (each element `{ path, new_position | old_position, body }`), which the SDK already accepts but gitea-axi previously left unpopulated — no new HTTP layer or endpoint is added.
A `reply_to` id not found among the PR's review comments is a `VALIDATION_ERROR` raised before submission, mirroring how `pr review` validates its action flags up front.
Mutation output follows the established action-block/entity-block convention; the inline-comment count is reflected in the reported result.
## Acceptance criteria
- [x] `pr review N --comment --comments-file <f>` submits a review whose payload `comments[]` reflects the file's entries
- [x] A new-comment entry maps `line` to `new_position` on the given `path`, always the new side
- [x] A reply entry (`reply_to`) posts an inline comment whose anchor is reconstructed from the target comment's `diff_hunk`, with side inferred from the target (no `side` field consumed)
- [x] The action flag remains required and top-level `--body` / `--body-file` still composes with the inline batch
- [x] A `reply_to` id absent from the PR's review comments yields `VALIDATION_ERROR` (exit 2) before any submission request is made
- [x] The mutation output reflects the inline-comment count in its result
- [x] Fixture-server tests assert the captured submission body's `comments[]` for both the new-comment (`path` + `new_position`) and reply (reconstructed anchor) cases, the `VALIDATION_ERROR` no-request failure path, and the action-block output
## Implementation Notes
- `--comments-file` parsing/validation and payload mapping live in a new module `src/review-comments.ts`. `loadInlineComments` reads and shape-validates the JSON batch up front (before any client is created); `resolveInlineComments` maps entries onto `CreatePullReviewComment[]`.
- The reply anchor is reconstructed by `anchorFromDiffHunk` in `src/diff.ts` (beside `trimDiffHunk`): it walks the unified-diff hunk — whose last line is the commented line, by Gitea's convention — tracking old/new line numbers, and reads the last line off. An added/context last line anchors on `new_position`; a deleted last line on `old_position`. Both sides are covered by tests.
- The reply-target lookup reuses a new `fetchAllReviewComments` in `src/review.ts` — the reviews-plus-comments fan-out the read side already performs — since Gitea has no get-comment-by-id endpoint. It only runs when a reply is present; a new-comment-only batch makes no extra GETs.
- The submitted count rides the action block as `review: { number, action, comments: N }`, added only when a batch was submitted so a plain review's output is unchanged.
- The action-flag-required criterion needed no new test: `resolveReviewAction` still runs first, so the existing zero/multiple-action tests cover it unchanged even with `--comments-file` present.
Review follow-ups (`/review-uncommitted`), all addressed in this branch:
- Standards flagged `readCommentsFile` as duplicating `body-source.ts`'s `readBodyFile`. Extracted the shared path-resolve-and-read into `src/flag-file.ts` (`readFlagFile`); both `--body-file` and `--comments-file` now go through it, keeping their flag-specific error messages.
- Spec flagged that an entry mixing both shapes (`reply_to` + `path`/`line`) was silently resolved to a reply. `validateEntry` now rejects the contradictory mix as a `VALIDATION_ERROR` up front (with a regression test), making the two-shape contract exact.
- Spec flagged the `target.path ?? ""` fallback as a silent mis-anchor. A reply target returned without `path`/`diff_hunk` now raises `UNKNOWN` instead of posting an empty path, mirroring the repo's other "never fabricate an anchor/identifier" guards.

View File

@@ -0,0 +1,49 @@
---
spec: nix-flake-packaging
---
## What to build
The packaging tier today holds two kinds of assertion joined only by an expensive shared setup step: assertions about the shape of the packed tarball and its manifest, and assertions that drive the resulting installed binary.
Split them, and teach the second group to accept the path of the binary to drive from the environment.
When the environment names an already-installed binary, the tier drives that binary directly and skips the pack-and-install setup entirely.
When it does not, the tier packs and installs exactly as it does today and drives the result.
This makes one seam that both the npm distribution path and — later — the Nix installation path call, so the two cannot drift apart in what they guarantee about an installed gitea-axi.
The tarball-shape assertions stay npm-only, since a Nix installation produces no tarball and no packed manifest.
The assertions themselves do not change in character: they drive a real subprocess, answer its HTTP calls with the in-process fixture server used throughout the suite, and point the `setup` command at a temporary home directory to observe the Agent Skill being written.
This tier deliberately inherits the parent environment, unlike the in-process CLI-seam harness — the spawned binary genuinely needs it.
Nothing may assert on store paths, wrapper internals, or the arrangement of files within the installed tree; those are implementation detail of the installation method.
## Acceptance criteria
- [x] The installed-binary assertions live separately from the tarball-shape assertions, and both still run under the packaging tier's own runner configuration.
- [x] An environment variable naming an existing binary makes the installed-binary group drive that binary and skip pack-and-install.
- [x] With that variable unset, the group packs, installs, and drives the result as before — the default developer experience is unchanged.
- [x] The full packaging tier passes in both modes.
- [x] No assertion in the installed-binary group depends on how the binary was installed.
## Implementation Notes
`test/packaging/packaging.test.ts` split into `tarball.test.ts` and `installed-binary.test.ts`, with the shared `npm pack` / extract / global-install mechanics factored into a non-test `npm-artifact.ts` module beside them.
The runner configuration is untouched apart from its comments: its `include` glob already matched the whole directory, so both new files run under it unchanged.
The environment variable is `GITEA_AXI_INSTALLED_BIN`, matching the existing `GITEA_AXI_*` convention.
An empty value counts as unset, so exporting it blank behaves the same as not exporting it.
A path that does not exist fails in `beforeAll` with an explanatory message rather than letting every assertion fail on an opaque spawn `ENOENT`.
Two deviations from the plan, both minor and both deliberate:
The single `it` that drove the installed binary became three — usage, dashboard render, and Agent Skill installation.
The task said the assertions "do not change in character", and they do not; this only splits one case into the three behaviours the spec itself names ("the binary runs, renders, and installs its Agent Skill"), so a failure names which one broke.
`PUBLISHING.md`'s "Verifying the packed artifact" section was rewritten to describe the two facets and document the new variable.
Not an acceptance criterion, but the section described the tier as one undifferentiated thing and would otherwise have been left stale by this change.
Verified by running the full tier three ways: with the variable unset (both facets pack as before), with it pointing at a separately installed binary (the installed-binary facet skipped its setup — 2.2s down to 0.3s, with no `prepack` build), and with it set to the empty string (falls back to pack-and-install).
One consequence worth flagging for task 0038: the two facets now each run `npm pack`, so `npm run test:pack` builds twice.
That is invisible to the Nix build, which will run only `test/packaging/installed-binary.test.ts` against its own installed output rather than the full tier.

View File

@@ -0,0 +1,104 @@
---
spec: nix-flake-packaging
---
## What to build
A Nix flake at the repository root exposing gitea-axi as a package, so the maintainer's NixOS configuration can add it to the system package set the way it adds anything else.
Building the package and running the resulting binary is the demoable outcome of this slice.
The derivation lives in its own expression, separate from the flake, in the conventional callable form nixpkgs uses.
The flake's own file stays limited to interface concerns — what it consumes and what it exports — leaving the derivation buildable outside a flake context and usable in an overlay unchanged.
The flake exposes only the package for now; the development shell and checks output arrive in a later slice, and no NixOS module or overlay is exposed at all.
Its nixpkgs input tracks the unstable channel, matching the maintainer's system; consumers deduplicate by pointing that input at their own, so it governs only standalone builds.
Systems coverage is the four common Linux and Darwin targets, enumerated with a small helper built from the nixpkgs standard library rather than a third-party systems-enumeration flake input.
Dependencies are fetched by deriving each package's fetch from the integrity fields already in the lockfile, not from a single fixed-output hash committed to the expression — the latter breaks on every lockfile change and is repaired by copying a hash out of an error message, which is a permanent recurring tax.
The package version is read from the package manifest at evaluation time, so a released version and its store path can never disagree.
The runtime Node is the nixpkgs default; Node 20 is not an option, as nixpkgs marks it end-of-life with known vulnerabilities.
The derivation's source is an explicit allowlist of the paths the build and its tests actually read — the TypeScript sources, the test tier, the bundled Agent Skill, the package manifest and lockfile, the two TypeScript configurations, and the default test-runner configuration.
Taking the whole repository, or a gitignore-derived filter, would let the highest-churn and entirely build-irrelevant directories invalidate the derivation and force a full rebuild with tests.
The installed binary is wrapped so the closure's `git` and `tea` are **appended** to the operator's existing search path, never prepended or substituted — ADR 0018 records why, and this slice lands that ADR.
The operator's own binaries win where present; the closure supplies a fallback so a fresh machine works with no manual setup.
The build runs the fast test tier, which needs a real `git` and a `which` available to it because some of its files invoke `git` directly and one resolves it by lookup.
The live end-to-end and benchmark smoke tiers are excluded — they require a live Gitea host.
The allowlist's failure mode is loud but disconnected from its cause, so this slice also records a Gotcha in the agent instructions: a new build-relevant top-level file must be added to the source allowlist or the Nix build fails on a missing file.
## Acceptance criteria
- [x] Building the flake's package from a clean checkout produces a runnable `gitea-axi` that prints help and reports its version.
- [x] The store path's version matches the package manifest's version, with the version appearing in no Nix expression.
- [x] Changing a dependency in the lockfile requires no edit to any Nix expression.
- [x] The derivation is a separate callable expression that the flake file consumes; it evaluates outside a flake context.
- [-] The package builds for the four supported Linux and Darwin systems, with no third-party flake input beyond nixpkgs.
- [x] Touching a file outside the source allowlist — an ADR, a spec, a benchmark file, prose documentation — does not change the derivation's output path.
- [x] The wrapped binary finds `git` and `tea` on a machine where neither is otherwise installed.
- [x] With the operator's own `git` and `tea` on the search path, those are the ones the binary invokes.
- [x] The fast test tier runs and passes inside the build; a deliberately failing test fails the build.
- [x] ADR 0018 is committed as part of this slice.
- [x] The agent instructions carry a Gotcha about extending the source allowlist for new build-relevant files.
## Implementation Notes
### Three systems, not four — `x86_64-darwin` is gone
The one dropped criterion. nixpkgs 26.11, which `nixos-unstable` now points at, has removed `x86_64-darwin` support outright.
`legacyPackages.x86_64-darwin` *throws* on evaluation rather than merely failing to build, so enumerating it would break `nix flake show` and `nix flake check` for **every** system at once, not just that one.
The flake therefore covers `x86_64-linux`, `aarch64-linux`, and `aarch64-darwin`, with the omission commented at the `systems` list.
Intel macOS would need the 26.05 branch; nobody is asking for it.
The rest of the criterion holds: nixpkgs is the only flake input.
### `doCheck = true` was silently doing nothing
`buildNpmPackage` wires config, build, and install hooks but supplies **no check hook**, so `doCheck` alone is inert.
The first green build logged `no Makefile or custom checkPhase, doing nothing` and produced a package whose tests had never run — a passing build that verified nothing.
The fix is an explicit `checkPhase`, plus `git` and `which` in `nativeCheckInputs` and a writable `HOME`.
Recorded as a Gotcha, since the failure mode is a *green* build.
The second half of that criterion was demonstrated unintentionally but genuinely: with the check phase live, a failing test failed the build with exit code 1 before anything was installed.
### The build tree is named, and why that is a workaround
`postUnpack` renames the build tree from the builder's generic `source` to `gitea-axi`.
This is not cosmetic. `test/setup.test.ts` asserts that `setup hooks` updates its managed entry in place rather than appending a duplicate.
The SDK recognises its own hook by testing whether the recorded command *string contains* `"gitea-axi"`, and the recorded command is the entrypoint's absolute path whenever PATH resolution does not match it — which it never does under vitest, since the entrypoint resolves to `src/main.js`, a file that does not exist.
So the assertion holds only when the checkout's path happens to contain `gitea-axi`.
That was verified rather than assumed: copying the repository to `/tmp/clean-probe-9d3/proj` and running the tier reproduces the failure outside Nix entirely.
A first probe under the session scratchpad passed and was misleading — that path contains `-home-alexion-wrk-gitea-axi-…`, so it satisfied the substring by accident.
The rename makes the build environment representative of a real installation (`node_modules/gitea-axi/…` under npm, `…-gitea-axi-<version>/…` under Nix) rather than an arrangement no operator ever has.
It is a workaround for a defect, not a property worth keeping, and is commented as such.
### Follow-up for task 0042 — the open verification item is answered, unfavourably
The spec's open verification item asked whether the SDK prefers the bare binary name over the absolute entrypoint path.
It does not.
`resolvePortableHookCommand` returns the bare name only when a `PATH` entry realpath-matches the entrypoint, and the absolute path otherwise.
Driving the Nix-built binary shows what actually lands in `~/.claude/settings.json`:
```
/nix/store/nmkzjny0hpzjvyxzdz189whk605di8b6-gitea-axi-0.1.0/lib/node_modules/gitea-axi/dist/main.js
```
That is content-addressed: it changes on every rebuild and is eventually garbage-collected, and a `SessionStart` hook that cannot execute simply does not run.
So the dashboard stops appearing after an upgrade, silently and with nothing pointing at the cause — the exact failure the item hoped to rule out, now confirmed under the install method this slice adds.
Two findings for 0042, both tracing to the same line:
1. The stale store path above — user-facing breakage, and the more serious of the two.
2. `setup hooks` appends a duplicate entry instead of updating in place whenever the entrypoint path lacks the marker.
Both were left alone deliberately, at the maintainer's direction, to keep this slice about packaging; fixing hook resolution here would have pre-empted 0042's design work with a decision made in passing.
When 0042 lands, the `postUnpack` rename should be removed with it.
### ADR 0018
Already committed in `0cbfe43` during the planning pass, ahead of this branch, so the criterion is satisfied by an earlier commit rather than by this one.
No change was needed; `package.nix` implements it via `--suffix PATH`.

View File

@@ -0,0 +1,71 @@
---
spec: nix-flake-packaging
blocked-by: [0036-parameterized-installed-binary-tier, 0037-flake-package-and-wrapper]
---
## What to build
After it installs, the Nix build drives the wrapped binary it just produced through the shared installed-binary tier, pointing that tier at the installed path rather than letting it pack and install.
This guards a class of failure the fast tier structurally cannot reach.
The compiler does not set the executable bit that npm would otherwise set from the manifest's `bin` entry at install time.
And the `setup` command resolves the bundled Agent Skill relative to its own module location, which makes the relative arrangement of the built output and the bundled Skill load-bearing — an arrangement that only exists once installed.
The check reuses the seam from the parameterized tier; it does not introduce a second, weaker set of assertions in shell script, and it does not re-run the fast tier, which would not exercise the installed layout at all.
## Acceptance criteria
- [x] The Nix build drives the installed binary through the shared installed-binary tier after installation.
- [x] A binary installed without its executable bit fails the build.
- [x] A bundled Agent Skill installed at the wrong location relative to the built output fails the build.
- [x] The post-install phase adds no assertions of its own beyond pointing the shared tier at the installed binary.
- [x] `nix build` still succeeds end to end on a clean checkout.
## Implementation Notes
### Where the check hangs
`installCheckPhase`, not `postInstall`.
It runs after `fixupPhase`, which is where `wrapProgram` has already done its work — so the binary the tier drives is the wrapped one an operator actually gets, not the bare entrypoint.
The phase sets `GITEA_AXI_INSTALLED_BIN=$out/bin/gitea-axi` and runs `npm run test:installed`, a new script that runs the installed-binary facet alone under the packaging runner configuration.
Naming the binary is all it does; the assertions stay in the tier, satisfying the fourth criterion by construction.
`vitest.packaging.config.ts` had to join the source allowlist in `package.nix` — exactly the Gotcha task 0037 recorded, hit on the first build.
### Dev dependencies are gone by install time
`npmInstallHook` runs `npm prune --omit=dev` against the build tree's `node_modules` during `installPhase`, so vitest no longer exists when `installCheckPhase` runs.
`preInstall` snapshots the tree with `cp -al` first — hardlinks, so it costs neither time nor space, and the prune's deletions do not follow through to the copy — and the check restores it.
Restoring copies rather than moves, so a replayed phase (`--keep-failed` debugging) does not consume the only surviving snapshot.
Recorded as a Gotcha, since the failure is disconnected from its cause.
### Criterion 2 holds, but by a different mechanism than the task assumed
The task motivates the executable-bit guard with "the compiler does not set the executable bit that npm would otherwise set from the manifest's `bin` entry".
That is true of the npm path and *not* of the Nix path: `nodejsInstallExecutables` installs each `bin` entry as a generated wrapper invoking `node <path>`, not as a symlink to the entrypoint.
So `chmod -x` on `dist/main.js` changes nothing — verified, the build stayed green — while `chmod -x` on `$out/bin/gitea-axi` fails all three tests with `EACCES`.
The criterion as written is therefore satisfied, and was demonstrated by probe, but the bit it protects under Nix is one `makeWrapper` always sets.
The assertion earns its keep on the npm caller, where the failure the task describes is real.
This is an argument for the shared tier rather than against it: neither installation path gets to pick which guarantees it feels like offering.
Both mechanism and probe results are recorded as a Gotcha.
The Skill-location criterion is the one doing real work under Nix — moving the installed `skills` directory aside fails the `setup` test and the build.
### Two defects found and fixed in passing
Both were surfaced by the review pass rather than planned.
`test:installed` pins a test file by path, and the packaging runner sets `passWithNoTests: true`.
Renaming or moving that file would have made vitest match nothing, exit 0, and take `nix build` green having asserted nothing — the same silently-inert trap that `doCheck` sprang in task 0037.
The script now passes `--passWithNoTests=false`.
`nix build --rebuild` reported the derivation "may not be deterministic".
The cause predates this task: `checkPhase`'s vitest leaves a run cache at `node_modules/.vite/…/results.json` recording durations and timestamps, and `npmInstallHook` copies `node_modules` into `$out` wholesale — so every build shipped a stray cache in the closure and no two outputs matched.
`checkPhase` now removes it, and `--rebuild` passes.
Strictly this belonged to 0037, but this task adds a second vitest run over the same surface, and the fix is one line in a file already being edited.
### Scope note
`.gitignore` gains `result` / `result-*`.
That is 0037's flake output rather than this task's, but the symlink appears the moment anyone runs `nix build` without `--no-link` and was already showing up as untracked.

View File

@@ -0,0 +1,53 @@
---
spec: nix-flake-packaging
blocked-by: 0037-flake-package-and-wrapper
---
## What to build
Two further flake outputs, so `nix develop` gives a declarative answer to "what do I need to work on this" and `nix flake check` is not a silent no-op.
The development shell carries the toolchain the repository actually needs: Node, `git`, and `tea` — enough for the build, the live end-to-end tier, and the benchmark harness, all of which the repository documents while specifying no toolchain anywhere.
It references the same Node attribute as the package, so development and the shipped artifact cannot drift onto different major versions.
The checks output aliases the package, so the conventional health-check command builds it and thereby runs both its verification phases.
Granular per-stage checks are deliberately not added: the one stage that would add real coverage is the full typecheck, which spans the test and benchmark directories and would therefore drag the benchmark harness into the derivation's inputs, undoing the source filtering.
The full typecheck stays in continuous integration, where it already runs.
## Acceptance criteria
- [x] `nix develop` yields a shell with Node, `git`, and `tea` available.
- [x] The build, the fast tier, and the benchmark harness's runner all work from inside that shell.
- [x] The shell's Node and the package's Node come from one reference — changing it moves both, and they cannot be set independently.
- [x] `nix flake check` builds the package and runs its tests, and fails when the package fails.
- [x] No per-stage check derivations are added.
## Implementation Notes
### The single Node reference is a `passthru`, not a second `pkgs.nodejs`
The shell takes `self.packages.${system}.gitea-axi.nodejs` rather than naming `pkgs.nodejs` again.
Naming it twice would satisfy the criterion's letter while leaving two places to edit, which is the drift the criterion exists to prevent; reading it back off the derivation means there is genuinely one reference.
The first cut relied on `buildNpmPackage` incidentally surfacing its `nodejs` argument as a derivation attribute — which works, but only as a side effect of `inherit src nodejs`, with nothing marking it load-bearing.
Review caught that: moving or dropping that `inherit` would have broken the shell silently at a distance.
`package.nix` now declares `passthru = { inherit nodejs; };`, making it an interface with a comment saying what depends on it.
`passthru` does not enter the derivation, so the store path is unchanged and the change costs no rebuild — verified: the drv hash before and after is identical.
### `curl` was a missing part of the toolchain
The benchmark's `raw-api` arm shells out to `curl` (`ARM_BINARY` in `bench/guard.ts`), so the shell carries it alongside Node, `git`, and `tea`.
### The shell deliberately does not supply `gitea-axi`
The criterion asks that the benchmark harness's runner work from inside the shell, and it does.
A *live* arm run is a further step the shell cannot take: `provisionArmBin` resolves each arm's binary by name off `PATH`, and the `gitea-axi` arm's binary must be the locally built `dist/main.js` so a run measures the working tree rather than whatever the flake last packaged.
Putting the packaged binary on `PATH` would satisfy the lookup with the wrong artifact — a silently misleading benchmark, worse than a missing one.
Exposing the *built* one was considered and rejected as well: `tsc` does not set an executable bit on `dist/main.js` (npm sets it at install time from the manifest's `bin` entry, which is what the packaging tier's bit assertion guards), so a `shellHook` would have had to `chmod +x` the build output on every shell entry — mutating build artifacts to work around a lookup that is the benchmark's own concern.
Both the flake and the CLAUDE.md gotcha now state the boundary rather than implying the shell covers it.
### CLAUDE.md's toolchain gotcha was stale by construction
It read "the repository has no dev shell yet (task 0039 adds one)".
This slice is that task, so the entry was rewritten to point at `nix develop --command`, keeping `nix shell nixpkgs#nodejs -c ...` only as the one-off outside the repository.

View File

@@ -0,0 +1,61 @@
---
spec: nix-flake-packaging
blocked-by: 0036-parameterized-installed-binary-tier
---
## What to build
Continuous integration moves off end-of-life Node, tests the full range of Node versions the package claims to support, and gains the two test tiers it currently never runs.
The workflow matrixes over the two supported Node versions, and the declared engine range in the package manifest narrows to match.
Today the manifest promises support down to Node 20 while testing only Node 20, so the entire claimed range below the tested version is unverified and its floor is end-of-life.
Narrowing is free right now because nothing has been published and no tags exist; that window closes at first publish.
The packaging tier currently asserts that the declared range mentions Node 20, so that assertion changes with it — part of this work rather than a later surprise.
The live end-to-end tier moves to the highest matrix leg only: it exercises the Gitea API contract rather than Node-version behavior, and each leg provisions a full Gitea service.
Two tiers join the workflow.
The benchmark harness tier runs on every leg — it is deterministic, needs no network or agent SDK, and is currently unguarded despite its non-default runner configuration being an easy thing to believe is running when it is not.
The packaging tier runs on the highest leg only, being slow and largely version-independent; it is the only automated guard on the distribution artifact, given that publishing is a manual command.
The benchmark smoke tier stays out: it targets a live host discovered through the maintainer's own credentials and skips cleanly when they are absent, so here it would pass by skipping — a green check that verified nothing.
The workflow keeps its container-and-npm shape and its GitHub Actions compatibility; nothing migrates to building via Nix.
## Acceptance criteria
- [x] The workflow runs a matrix over the two supported Node versions, and no leg runs an end-of-life Node.
- [x] The manifest's declared engine range names exactly the versions the matrix tests.
- [x] The packaging tier's engine assertion matches the narrowed range and passes.
- [x] The live end-to-end tier runs on the highest leg only.
- [x] The benchmark harness tier runs on every leg, under its own runner configuration.
- [x] The packaging tier runs on the highest leg only.
- [x] The benchmark smoke tier does not run.
- [x] The workflow syntax stays GitHub-Actions-compatible.
## Implementation Notes
The supported majors are 22 and 24 — the two current long-term-support lines, and the pair that brackets the nixpkgs default the flake already builds against (24.18.0).
The engine range became `^22 || ^24` rather than `>=22`, so it names those two majors and nothing else; a bare floor would have re-promised the odd-numbered 23, which is itself end-of-life.
The single matrix job was kept rather than split into a matrixed job plus a separate single-version job for the once-only tiers.
The acceptance criteria are phrased in terms of matrix legs, one job keeps the version list in exactly one place, and the task asked that the workflow keep its container-and-npm shape.
The cost is that the Gitea service container starts on the Node 22 leg without the end-to-end tier consuming it — cheap next to running that tier twice, which is what the spec's rationale was actually guarding against.
The once-only steps condition on `matrix.highest`, a flag attached to the `24` leg through a `strategy.matrix.include` entry, rather than on `matrix.node == '24'` at each site.
An `include` entry whose keys match an existing combination augments that leg rather than adding a new one, on both GitHub Actions and Gitea Actions, and an undefined context property is falsy on the other leg.
Two consequential changes beyond the criteria, both surfaced by review:
`@types/node` moved from `^20.19.0` to `^22.20.1`.
It was the last Node 20 reference left in the manifest, and the typecheck runs on every leg.
It tracks the *floor* rather than the highest leg deliberately: typings for 24 would let source compile against APIs the Node 22 leg does not have.
`.claude/spec/gitea-axi.md`'s "Language and Runtime" section still read "TypeScript on Node 20+", which this change falsified.
Verified by running the whole matrix's worth of tiers locally through `nix develop`: typecheck, the fast tier with coverage thresholds (410 tests), the benchmark harness tier under its own runner configuration (117), and the full packaging tier (7), plus `nix build .#gitea-axi` after the lockfile moved.
The workflow was parsed with `yq` to confirm the matrix, the container expression, and the two `if:` conditions land where intended.
The live end-to-end tier was not run locally — it needs a disposable Gitea instance, and the diff only gates it.
One follow-up worth flagging: `vitest.bench.config.ts` sets `passWithNoTests: true`, so if its include glob ever broke, the new benchmark step would go green while running nothing — precisely the failure mode this task cites as the reason to add the step.
Pre-existing, and left alone here rather than widened into a test-configuration change.

View File

@@ -0,0 +1,71 @@
---
spec: nix-flake-packaging
blocked-by: 0037-flake-package-and-wrapper
---
## What to build
A separate continuous-integration job that builds the flake, on both push and pull request.
Its value is detecting flake rot — most concretely, a build-relevant file omitted from the source allowlist — at the commit that causes it rather than weeks later at the maintainer's next system rebuild.
It is deliberately non-gating for the other jobs, so an infrastructure problem with Nix availability on the runner does not block an otherwise legitimate change.
Its cost is honest and accepted: because the checks output aliases the package, this job re-runs the fast tier inside the derivation and, without a warm store, rebuilds the whole dependency closure.
## Acceptance criteria
- [x] A distinct job builds the flake on push and on pull request.
- [x] Its failure does not block or fail the other jobs.
- [x] Removing a build-relevant file from the source allowlist makes this job fail.
- [x] The job's cost and its non-gating intent are stated in the workflow so neither reads as an oversight.
## Implementation Notes
The job sits alongside `test` in the existing workflow rather than in a file of its own, so it inherits the `on:` triggers already there — push to `main` and every pull request — with no second copy to keep in step.
Non-gating is achieved two ways, and both are needed.
The absence of a `needs:` edge means the two jobs run concurrently and neither waits on the other, so a slow or failing flake build cannot hold the test job back.
`continue-on-error: true` then keeps a red flake job from failing the workflow run as a whole, which is the part that would otherwise block a merge.
That combination is what the criterion asks for; either alone leaves a gap.
### `continue-on-error` goes on the steps, not the job
The obvious spelling — `continue-on-error: true` as a job key — is inert on the platform this workflow primarily targets, and the first draft of this task had it there.
Gitea Actions runs on a fork of `act`, and that fork's `pkg/model/workflow.go` declares `RawContinueOnError` on its **`Step`** struct only; the `Job` struct has no such field.
A job-level flag is therefore parsed as an unknown key and silently ignored, so a failing `nix flake check` would have failed the whole workflow run — exactly the merge-blocking outcome the criterion forbids, and with nothing in the logs to say why.
Gitea's own comparison page does not list the gap, which is presumably how it survives; go-gitea#25897 mentions it in passing while reporting the sibling gap in `jobs.<id>.if`.
Moving the flag onto both steps fixes it and costs no portability: step-level `continue-on-error` is honoured by `act` and GitHub Actions alike, and a job whose every step carries it concludes green on either platform.
The install step carries it too, not just the build — an action that fails to fetch or install Nix is precisely the infrastructure failure the non-gating stance exists to absorb.
### `nix flake check`, not `nix build`
The two build the same derivation, since the `checks` output aliases the package.
The check output is what a consumer would verify with, though, so exercising that path additionally catches a `checks` output that has stopped evaluating.
One limit worth recording: `nix flake check` builds the current system's outputs and merely *evaluates* the others, so the two systems the runner is not — `aarch64-linux` and `aarch64-darwin` — are type-checked rather than built.
`--all-systems` would not change that; building them needs runners of those architectures.
The job's value is the allowlist guard, which is architecture-independent, so this is a limit rather than a shortfall.
### The allowlist criterion was verified, not assumed
Deleting `./tsconfig.build.json` from `package.nix`'s `lib.fileset.unions` and re-running `nix flake check` locally fails the build in `buildPhase`:
```
> tsc -p tsconfig.build.json
error TS5058: The specified path does not exist: 'tsconfig.build.json'.
```
`package.nix` was restored immediately afterwards; the probe is not in the diff.
This is the failure mode the job exists to catch, and it confirms the loud-but-disconnected shape the 0037 Gotcha describes — the error names the missing file, never the allowlist that omitted it.
### `cachix/install-nix-action` is a third-party action
Nix is not in the runner image, so the job installs it.
That is the one dependency in this file on an action outside `actions/`, resolved from github.com by Gitea Actions the same way `actions/checkout` is.
It is also the most likely source of the infrastructure flakiness `continue-on-error` exists to absorb, which is part of why that flag is set rather than merely tolerated, and why the install step carries it as well as the build step.
It is pinned to a mutable major tag (`@v31`), matching how `actions/checkout@v4` is pinned elsewhere in the file rather than introducing a second convention.
A commit SHA would be the supply-chain-tight choice; the exposure here is a non-gating job holding no credentials and no `secrets` access, and pinning one action by SHA while the rest of the file uses tags would be inconsistent without being materially safer.
Worth revisiting as a file-wide decision rather than a local one.

View File

@@ -0,0 +1,98 @@
---
spec: nix-flake-packaging
---
## What to build
Resolve the spec's one open verification item, then act on what is found.
The `setup` command's hook installation passes the agent 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 that records it would break silently — 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 that could not be confirmed during design because the dependency was not installed.
The decision is to verify before acting.
Determine, against the installed SDK, which of the two the hook installation actually records.
If it prefers the bare name, record the finding and close the item — no code changes.
If it records 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 is explicitly **not** part of this task: it would 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.
## Acceptance criteria
- [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
Task 0037 built the flake, which made the SDK's behaviour directly observable.
The answer is the unfavourable one: **the absolute path is recorded**, so the mitigation branch of this task applies, not the close-the-item branch.
`resolvePortableHookCommand` in `axi-sdk-js` returns the bare binary name only when a `PATH` entry realpath-matches the entrypoint, and the absolute path in every other case.
Driving the Nix-built binary writes this into `~/.claude/settings.json`:
```
/nix/store/nmkzjny0hpzjvyxzdz189whk605di8b6-gitea-axi-0.1.0/lib/node_modules/gitea-axi/dist/main.js
```
That path is content-addressed, so it changes on every rebuild and is eventually garbage-collected, and the session-start hook then silently stops running.
A second defect surfaced from the same line.
`isManagedHook` recognises its own hook by testing whether the recorded command *string contains* the marker `"gitea-axi"`, so when the entrypoint path lacks that substring, `setup hooks` appends a duplicate entry instead of updating in place — contradicting the idempotency its help text promises.
This is reproducible outside Nix: copy the checkout to a path containing no `gitea-axi` segment and `test/setup.test.ts` fails.
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,68 @@
---
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
- [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.

View File

@@ -0,0 +1,52 @@
---
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
- [x] An unwritable skill target produces a structured CLI error rather than an unhandled filesystem exception.
- [x] An unwritable hook target produces the same class of error, with the same guidance.
- [x] Both errors name the file that could not be written and state that it appears to be managed by another tool.
- [x] Neither error names or infers a specific configuration manager.
- [x] A skill target that is unwritable but already byte-identical to the bundled copy succeeds rather than failing, since nothing needs to be written.
- [x] The errors carry a code and help lines consistent with the rest of the CLI's error surface.
## Implementation Notes
The condition is `EACCES`, `EPERM`, or `EROFS` — the three ways a filesystem refuses a write for a reason the user has to settle outside this tool.
The new `TARGET_NOT_WRITABLE` code joins the spec's enumerated list, alongside a paragraph describing it.
Two things came out of review and go slightly beyond the literal criteria.
The skill half now guards its comparison read as well as its write.
A target the filesystem will not let us read is one it will not let us replace either — the same condition reached one call earlier — so a mode-`000` file reports the same error rather than the raw exception the criteria were written against.
The hook half collects its failures as `{path, detail}` records rather than the agent SDK's flattened `<path>: <message>` text.
The SDK reports through a string, so the two halves are separated once at that boundary and judged apart.
This matters for correctness, not just shape: testing the whole formatted string for an errno would misclassify an unrelated failure whose *path* happened to contain `EACCES`.
Two known limits, both judged acceptable rather than fixed.
The hook error names the target the SDK was writing, which is the intended path rather than necessarily the blocking one — if `~/.claude` were unwritable and `settings.json` absent, it would name the file rather than the directory.
The SDK discards the error object, so its `path` is not recoverable; the skill half, which catches its own errors, does report the blocking path and is tested for it.
An unwritable `~/.claude/settings.json` still leaves the Codex and OpenCode integrations installed, because the SDK writes them before the failure surfaces.
The command exits 1 having done part of its work.
Making the hook install transactional across three integrations owned by the SDK is a larger change than this task, and re-running after fixing the permission converges correctly.

View File

@@ -0,0 +1,83 @@
---
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
- [x] The bundled Agent Skill is installed to a stable location in the package output that is not an internal implementation path.
- [x] The package exposes the Skill and the hook specification as attributes consumable from a Nix expression without building or running anything.
- [x] The hook specification is declared in a single committed file, read by both the Nix expression and the test suite.
- [x] A test drives the imperative hook install and asserts that what it writes matches the declared specification, failing if either side drifts.
- [x] The flake exposes a home-manager module that declares the Skill and the hook.
- [x] Importing the module without enabling it changes nothing about the resulting configuration.
- [x] The module installs the package by default, and accepts a null package as the documented way to declare the configuration without installing the binary.
- [x] The Skill and the hook each have their own toggle, both defaulting to on.
- [x] The module composes with an existing configuration that already declares its own SessionStart hooks and skills, rather than conflicting with it.
- [x] 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.
- [x] The user-facing documentation describes both installation paths and when each applies.
## Implementation Notes
The decision is recorded as [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md).
The spec's Out of Scope entry is deleted and its "Flake surface" section rewritten to record the reversal rather than to pretend the deferral never happened.
### The hook specification is the settings entry, not its parts
`session-start-hook.json` holds the SessionStart entry verbatim as it belongs in a Claude Code `settings.json` — matcher, and the hook array inside it — rather than the fields the entry is assembled from.
Declaring the fields would have left the *grouping* restated in both the Nix expression and the test, which is exactly the kind of second source of truth the file exists to prevent.
As written, the Nix expression is `[ sourcePackage.sessionStartHook ]` and the test is a deep-equality against the same value, so neither restates anything.
The file's contents were derived by observation — running the installed binary against a temporary home and reading what the agent SDK wrote — and the new test was confirmed to fail when the declaration is perturbed, rather than being assumed to bite.
### The module declares through `programs.claude-code`, not through `home.file`
Writing `~/.claude/settings.json` directly would collide with home-manager's own Claude Code module, so the module sets that module's options and lets home-manager's merge semantics compose.
Verified against real home-manager before landing, on five configurations: importing without enabling produces a **byte-identical** generation to never importing at all; enabling alongside a configuration that already declares its own SessionStart hook and its own skill yields both of each; `package = null` installs no binary but still declares the Skill; the skill-only toggle declares no hook; and omitting `programs.claude-code.enable` fails the assertion with the intended message.
### `package = null` still sources the Skill from the default build
The task called null "the documented opt-out for an operator who supplies the binary another way", which settles where the *binary* comes from but not where the Skill's bytes do.
They come from the default build, which for the intended case — a system-wide install of this same package — is already in the closure.
The sharp edge is an operator whose system-wide copy is a different build: their Skill would come from a package they are not running.
That is documented on the option itself rather than designed away, since the alternative is refusing to declare a Skill at all in the one arrangement the null value exists to serve.
### Two limitations documented rather than fixed
Home-manager inspects the Skill path during evaluation, so a rebuild realises the package at evaluation time even under `package = null`.
This is inherent to sourcing the Skill from the package and is a rebuild-latency cost, not a correctness one; the alternative would ignore `package` overrides entirely.
`programs.claude-code.skills` also accepts a bare path standing for a whole skills directory, and a configuration using that form cannot have an entry merged into it.
Both are recorded in INSTALL.md and in the ADR's Consequences.
### Follow-up worth flagging
The repository has no `README.md`, so `INSTALL.md` — which follows the existing convention of topic-scoped root documents alongside `PUBLISHING.md` — is discoverable only by browsing the repository, and is not in the npm `files` allowlist so it does not ship in the tarball.
Neither was changed here: adding a README is its own piece of work, and installation instructions inside an already-installed tarball are of little use.

View File

@@ -0,0 +1,48 @@
---
spec: hm-module-harness-integration
---
## What to build
Reshape the home-manager module so that enabling gitea-axi means "install the CLI, always", and the Claude Code agent context follows only when Claude Code is present.
`programs.gitea-axi.enable` installs the binary unconditionally.
The two per-artefact toggles (`skill.enable`, `sessionStartHook.enable`) and the assertion that fired when either was on without `programs.claude-code.enable` are removed, replaced by a single per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting to a literal `true`.
When the integration toggle is on, both Claude Code artefacts are declared; they land only when `programs.claude-code.enable` is also on, and are silently absent otherwise, with no assertion — matching how home-manager's own `enableBashIntegration`-style toggles behave against a disabled sibling.
The SessionStart hook stays declared through the Claude Code module's `settings.hooks.SessionStart` option, so it composes with an operator's own hooks and inherits that module's own enable-gate for free.
The Agent Skill moves off `programs.claude-code.skills` and is written through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, sourced from the package's published Skill.
Because it no longer rides the Claude Code module's options, it no longer inherits that module's enable-gate, so the module gates the Skill write explicitly on `programs.claude-code.enable` (in addition to `enable` and the integration toggle).
This explicit gate also keeps package realisation lazy — the file mechanism reads the Skill's source path during evaluation, so an ungated write would realise the package on every host.
The module comments the resulting asymmetry: the Skill gated by an explicit sibling-enable condition, the hook by the sibling module's own gate.
Writing the Skill as an ordinary file declaration at its own path (rather than as a contribution to the skills option's type) fixes the path-form collision at its root: it composes with both the attribute-set and whole-directory forms of an operator's own `programs.claude-code.skills`.
`programs.gitea-axi.package` is unchanged in meaning, including the `package = null` path, which declares the Skill and hook from the default build without installing the binary.
INSTALL.md is updated to match the new option surface: the options table, the `package = null` paragraph, and the removal of the path-form skills limitation paragraph (the limitation no longer exists).
## Acceptance criteria
- [x] `programs.gitea-axi.enable = true` puts the binary on the operator's packages regardless of whether Claude Code is enabled.
- [x] `skill.enable`, `sessionStartHook.enable`, and the assertion are gone; `programs.gitea-axi.enableClaudeCodeIntegration` exists and defaults to a literal `true` (not derived from `programs.claude-code.enable`).
- [x] Enabling gitea-axi on a host with `programs.claude-code.enable = false` evaluates successfully and installs no Skill and no hook — no assertion failure.
- [x] With `enableClaudeCodeIntegration` and `programs.claude-code.enable` both on, the Skill is written through home-manager's file mechanism into Claude Code's skills directory under `gitea-axi`, and the hook is declared through `programs.claude-code.settings.hooks.SessionStart`.
- [x] The Skill write is gated on `programs.claude-code.enable` explicitly so the package's Skill source is not realised on a host without Claude Code; the hook has no such explicit gate and the asymmetry is commented.
- [x] `package = null` still declares the Skill and hook from the default build without adding the binary to the operator's packages.
- [x] INSTALL.md's options table and `package = null` paragraph reflect the new surface, and the path-form skills limitation paragraph is removed.
## Implementation Notes
The module reads Claude Code's skills location from `config.programs.claude-code.configDir` rather than hardcoding `.claude/skills`, mirroring the sibling module exactly so the Skill lands beside Claude Code's own skills wherever the operator points that option.
Confirmed against the current home-manager `claude-code` module: it lowers a path-form skills directory to a *recursive* `home.file` install (individually-linked files under `configDir/skills`), which is what lets the module's own `configDir/skills/gitea-axi` entry coexist as a sibling.
Task 0047 turns that coupling into an automated check.
The asymmetric gating the spec calls for is expressed structurally: the hook is declared under `enableClaudeCodeIntegration` alone and relies on the Claude Code module's own `mkIf enable` to drop it when disabled; the Skill adds a nested `mkIf claudeCode.enable` because writing through `home.file` does not inherit that gate, and the gate additionally keeps package realisation lazy.
The asymmetry is commented in the module.
Verified by evaluating the real module through `home-manager.lib.homeManagerConfiguration` (the same seam task 0047 automates) under four configurations: both-on (binary + Skill file + hook in settings), Claude Code off (binary only, no Skill, no assertion failure), integration off (binary only, no Skill, no hook), and `package = null` with Claude Code on (Skill declared, gitea-axi absent from `home.packages`).
All matched.
Task 0047 (the flake check and home-manager input) is staged in the same commit as an untracked planning artifact but is implemented separately.

View File

@@ -0,0 +1,49 @@
---
spec: hm-module-harness-integration
blocked-by: 0046-reshape-hm-module-per-harness-toggle
---
## What to build
Add the first automated proof of the module's composition, so that a change in home-manager or the Claude Code module that breaks the way the Skill is declared fails `nix flake check` rather than a maintainer rebuild weeks later.
A home-manager input is added to the flake, with its own nixpkgs following the flake's nixpkgs, so the module is checked against the same nixpkgs-and-home-manager pairing a consumer following this flake would get.
A flake check evaluates the actual module through home-manager's standalone configuration entry point and builds the resulting home files derivation under several configurations, asserting on the tree it produces.
Building the home files derivation is sufficient — it is the file-linkage layer that actually decides whether two declarations collide — and needs neither the Claude Code binary nor a running agent.
The check asserts on which files a generation contains, never on module internals (option values, store paths, the shape of the file mechanism).
The configurations exercised:
- An operator declaring their own skills as an attribute set: the module's Skill lands alongside the operator's, each at its own name.
- An operator declaring their own skills as a whole directory (path form): the module's Skill lands alongside the operator's directory contents.
This is the case that guards the recursive-install coupling; a regression to a non-recursive path-form install fails here as a build-time file collision.
- Claude Code disabled: no gitea-axi Skill entry is written.
This guards the explicit sibling-enable gate introduced in the reshape.
- The hook merges into an operator's own SessionStart hook list rather than replacing it.
This adds a module check alongside the existing package check in the flake's `checks` output rather than introducing a new kind of verification surface.
## Acceptance criteria
- [x] The flake has a home-manager input whose nixpkgs follows the flake's nixpkgs; `flake.lock` is updated.
- [x] A new check under the flake's `checks` output evaluates the real module through home-manager's standalone configuration entry point and builds the home files derivation — no Claude Code binary or running agent required.
- [x] The attribute-set-skills configuration asserts the module's Skill and the operator's skill both land, each at its own name.
- [x] The whole-directory-skills (path form) configuration asserts the module's Skill lands alongside the operator's directory contents; a non-recursive path-form install would fail this as a build-time collision.
- [x] The Claude-Code-disabled configuration asserts no gitea-axi Skill entry is written.
- [x] A configuration with an operator's own SessionStart hook asserts the module's hook merges into that list rather than replacing it.
- [x] The check asserts on the generation's file tree only, not on option values or store paths.
- [x] `nix flake check` runs the new check across the flake's systems and passes.
## Implementation Notes
The check lives in its own file, `checks/home-manager-module.nix`, imported from the flake's `checks` output beside the existing package check; the `forAllSystems` callback now destructures `{ pkgs, system }` because the check needs `pkgs` to build fixtures and the derivation.
`home-manager` is added as a flake input with `inputs.nixpkgs.follows = "nixpkgs"`; it is a check-only development input with no bearing on the package or the module a consumer imports, and the comment in `flake.nix` says so.
Each of the four configurations builds `config.home-files` — the home-manager file-linkage layer — and the final `runCommandLocal` asserts on that tree with `test`/`grep` only: skill files present or absent by path, and the two SessionStart commands present as quoted JSON string values in `settings.json`.
The hook-merge assertion reads generated file content because that is the only place a list merge is observable; the spec's Testing Decisions name "the hook merges into the operator's own hooks" as a required assertion, so this stays within "assert on the file tree, not on option values or store paths".
On the cross-system criterion: `nix flake check` builds the check for the host system and passes, and omits the incompatible systems (`aarch64-*`) with a warning — identical per-system semantics to the pre-existing package check, which also builds only natively.
The check is import-from-derivation-bearing (the Claude Code module reads the fixture skill directories at evaluation time), so evaluating a foreign system's check forces a cross-platform fixture build rather than skipping cleanly; this is not exercised by the default `nix flake check` and does not affect the native run.
Verified by `nix flake check` (passes, all outputs) and by inspecting each configuration's built `home-files` tree directly: the disabled-Claude-Code generation contains no `.claude` directory at all, and the merged-hook `settings.json` contains both hooks in the `SessionStart` array — confirming the assertions are not vacuous.

View File

@@ -2,7 +2,7 @@
# is kept GitHub-Actions-compatible so the GitHub mirror can adopt this file
# nearly verbatim (copy it to .github/workflows/).
#
# The job runs inside a node container so the disposable Gitea service is
# The `test` job runs inside a node container so the disposable Gitea service is
# reachable by its service name (`gitea:3000`) on both Gitea Actions and GitHub
# Actions — avoiding the host-vs-service-name networking difference between the
# two platforms.
@@ -16,7 +16,22 @@ on:
jobs:
test:
runs-on: ubuntu-latest
container: node:20-bookworm
container: node:${{ matrix.node }}-bookworm
strategy:
# Every leg's result is wanted: a failure on one Node major says nothing
# about the other, and cancelling the sibling would hide half the answer.
fail-fast: false
matrix:
# The supported Node majors, matching the manifest's declared engine
# range. Node 20 is end-of-life and deliberately absent.
node: ["22", "24"]
# Augments the highest leg with a flag, so the steps that run once
# share one named condition rather than each restating a version
# number. Keep this entry's `node` matching the last element above.
include:
- node: "24"
highest: true
services:
gitea:
@@ -50,5 +65,64 @@ jobs:
- name: Unit and integration tiers (with coverage thresholds)
run: npm run test:coverage
# Deterministic, and needs neither network nor the agent SDK, so it runs
# everywhere. Its non-default runner configuration makes it an easy tier
# to believe is running when it is not.
- name: Benchmark harness tier
run: npm run test:bench
# Exercises the Gitea API contract rather than Node-version behaviour, so
# one leg is enough.
- name: End-to-end tier
if: matrix.highest
run: npm run test:e2e
# Slow, and near enough version-independent — but the only automated guard
# on the distribution artifact, since publishing is a manual command.
- name: Packaging tier
if: matrix.highest
run: npm run test:pack
# Builds the flake, catching flake rot — most concretely a build-relevant file
# left out of package.nix's source allowlist — at the commit that causes it,
# rather than weeks later at the maintainer's next system rebuild.
#
# `continue-on-error` is deliberate, not an oversight: this job is non-gating.
# Nix is not part of the runner image, so an infrastructure problem installing
# or reaching it must not block an otherwise legitimate change. Read its result
# as a signal, not as a verdict — a red mark here still merges.
#
# Its cost is likewise accepted rather than accidental. The flake's `checks`
# output aliases the package, so this builds the whole dependency closure from
# cold — nothing warms the store between runs — and re-runs the fast tier and
# the installed-binary tier inside the derivation, both of which the `test` job
# has already run. That duplication buys the allowlist guard, which nothing
# else provides.
flake:
runs-on: ubuntu-latest
# No `needs`: it neither waits on the test job nor is waited on, so the two
# run concurrently and neither can hold the other back.
#
# `continue-on-error` is set per step rather than on the job, which reads as
# the odd spelling but is the only one that works here: Gitea's `act` fork
# has the field on its Step struct and not on its Job struct, so a job-level
# flag is parsed and silently ignored, and a red build would fail the run
# after all. Step-level is honoured by both act and GitHub Actions, and a job
# whose every step is continue-on-error concludes green on either — so this
# spelling keeps the file portable as well as correct.
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
continue-on-error: true
with:
extra_nix_config: |
experimental-features = nix-command flakes
# `checks` is the package, so this builds exactly what `nix build` does,
# entered through the output a consumer would verify with — which also
# catches a `checks` output that has stopped evaluating.
- name: Check the flake (builds the package)
continue-on-error: true
run: nix flake check --print-build-logs

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@ node_modules/
dist/
coverage/
bench/results/
result
result-*

View File

@@ -9,14 +9,80 @@ Any commit message you write must follow the Conventional Commits specification
The `origin` remote is a self-hosted **Gitea** instance (`git.alexion.dev`), not GitHub.
The `gh` CLI does not work here.
The benchmark arms invoke the **built `dist/main.js`** (the `gitea-axi` binary on `PATH`), not the TypeScript source.
Run `npm run build` before any live `bench:run` if you want `src/` changes reflected; the bench does not run from source.
Prefer this project's own CLI for pull requests — it is the tool being built, so opening its PRs with it is the dogfood path:
`npm run build && node dist/main.js pr create --login alexion --base main --head <branch> --title <text> --body-file <path>`.
It reuses the `tea` login profiles, so it needs no separate credentials.
It reuses the `tea` login store, which here holds exactly `alexion` — there is no `axi` profile, and the `csv-reviewer` profile that used to exist is gone for good.
`selectLogin` matches the `--login` value against those names exactly, so `--login alexion` works and an unknown name like `--login axi` fails with `VALIDATION_ERROR` ("Login profile "axi" not found").
Fall back to `tea pr create --login alexion --base main --head <branch>` only for what gitea-axi cannot do yet; `tea pr` still lists PRs until `pr list` lands (task 0008).
The same login store backs the benchmark: `npm run bench:run -- --arm <arm> --login alexion --task <id>` (or set `GITEA_AXI_BENCH_LOGIN=alexion`).
The `bench/` unit tests only run under their own Vitest project config: `npx vitest run --config vitest.bench.config.ts bench/<file>.test.ts`.
Plain `npx vitest run bench/<file>.test.ts` reports "no tests" because the default `vitest.config.ts` includes only `test/**`.
Task branches are merged into `main` on the remote, so the local `main` goes stale.
Always `git fetch origin` and cut a task branch from `origin/main`, not from whatever local `main` happens to point at.
Gitea's issue/PR search endpoint (`GET /repos/issues/search`, behind `search issues`/`search prs`) is backed by an **asynchronous, eventually-consistent issue indexer** (bleve by default).
Content created moments earlier may not be searchable yet, so end-to-end assertions that create an issue/PR and then search for it must poll (e.g. `expect.poll`) until it is indexed rather than searching once.
The fixture tier is unaffected — it stubs the endpoint — so this bites only the live `test/e2e` tier.
`tea` is **still a runtime dependency**, despite [ADR 0002](.claude/adr/0002-direct-gitea-api-over-tea-subprocess.md) being titled "use direct Gitea API instead of wrapping the `tea` subprocess".
That ADR moved *command dispatch* to `gitea-js`; it explicitly kept `tea` for **credential discovery**, and its own Consequences section says so.
Per [ADR 0001](.claude/adr/0001-diff-auth-via-tea-login-list.md) as amended, `src/context.ts` resolves auth by shelling out to `tea login list --output json` (discovery) and `tea login helper get --login <name>` (token, with in-place OAuth refresh).
The only bypass is the test hook requiring `GITEA_AXI_API_URL` + `GITEA_AXI_TOKEN` + `GITEA_AXI_REPO` together; there is no user-facing path that avoids `tea`, and `TEA_NOT_INSTALLED` exists for its absence.
Neither `node`/`npm` nor `tea` is on the `PATH` in a non-interactive shell on this machine, and there is no `~/.gitconfig`.
This is a NixOS host with no global Node install, so run work through the flake's dev shell: `nix develop --command <cmd>`, which carries Node, `git`, `tea`, and `curl` — enough for the build, the fast tier, the live end-to-end tier, and the benchmark harness's own runner.
That covers anything resolving credentials, including `gitea-axi pr create`.
The shell deliberately does *not* put `gitea-axi` on the `PATH`, so a live `bench:run` of the `gitea-axi` arm still needs the built binary exposed under that name itself — see the benchmark gotcha above.
`nix shell nixpkgs#nodejs -c ...` still works for a one-off outside the repository, but inside it the dev shell is the declarative answer and pins the same Node the package is built against.
`node_modules/` may be absent too, so `npm ci` first.
Commits need an explicit identity: `git -c user.name=alexion -c user.email=contact@alexion.dev commit ...`, matching the existing history.
The Nix derivation's source is an **explicit allowlist** in [`package.nix`](package.nix), not the whole repository and not a gitignore filter.
A new build-relevant top-level file — a TypeScript configuration, a runner configuration the fast tier loads, a directory the build reads — must be added to that `lib.fileset.unions` list or `nix build` fails on a missing file.
The failure is loud but disconnected from its cause: the error names the missing file, not the allowlist that omitted it.
The flip side is the point of the design — touching an ADR, a spec, a task, a `bench/` file, or prose documentation must *not* change the derivation's output path.
`nix flake check` on a **dirty working tree** intermittently fails with `error: path '<hash>-source' is not valid`, naming the store path of the derivation's filtered source.
It is an evaluation-cache artefact of the dirty-tree flake source being re-created, not a fault in the expression — running `nix build .#gitea-axi --dry-run` first makes the very next `nix flake check` pass unchanged.
Reach for that before debugging the `lib.fileset` allowlist, which the error's wording otherwise points straight at.
`buildNpmPackage` provides **no check hook**, so `doCheck = true` on its own is silently inert — the build logs `no Makefile or custom checkPhase, doing nothing` and ships a package whose tests never ran.
Running the fast tier inside the derivation requires an explicit `checkPhase`; it also needs `git` and `which` in `nativeCheckInputs` and a writable `HOME`, since some of those tests shell out to `git`.
`nodejsInstallExecutables` (inside `npmInstallHook`) installs each manifest `bin` entry as a **generated wrapper that invokes `node <path>` explicitly**, not as a symlink to the entrypoint.
So under Nix the executable bit on `dist/main.js` is not load-bearing — `chmod -x` on it changes nothing — while the bit on `$out/bin/gitea-axi` is, and makeWrapper always sets it.
The npm path differs: there the `bin` entry is symlinked and npm sets the bit at install time.
`npmInstallHook` runs `npm prune --omit=dev` against the **build tree's** `node_modules` during `installPhase`, so dev dependencies (vitest included) are gone by the time `postInstall` or `installCheckPhase` runs.
Anything that needs them after install must snapshot the tree in `preInstall` first; `cp -al` is the cheap way, since the prune's deletions do not follow hardlinks.
Running vitest inside the derivation leaves a run cache at `node_modules/.vite/…/results.json` that records durations and timestamps, and `npmInstallHook` copies `node_modules` into `$out` wholesale.
Left alone it ships a stray cache in the closure and makes the output non-reproducible — visible only under `nix build --rebuild`, which reports "may not be deterministic"; an ordinary build stays green.
`checkPhase` deletes it before the install phase runs.
nixpkgs 26.11 (the `nixos-unstable` the flake tracks) has **dropped `x86_64-darwin`**.
`legacyPackages.x86_64-darwin` now *throws* rather than merely failing to build, so listing that system in the flake's `systems` breaks `nix flake show` and `nix flake check` for every system at once, not just that one.
Intel macOS would need the 26.05 branch.
Gitea Actions ignores **job-level `continue-on-error`**.
Its `act` fork declares `RawContinueOnError` on the `Step` struct only — `pkg/model/workflow.go` has no such field on `Job` — so `jobs.<id>.continue-on-error` is parsed as an unknown key and silently dropped, and the job fails the run as if the flag were never written.
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.
`resolvePortableHookCommand` in `axi-sdk-js` returns the bare binary name only when a `PATH` entry *realpath-matches* the **`execPath` it is handed**, and the absolute path otherwise.
Passing `binaryNames` does not by itself make the hook portable: npm symlinks its `bin` entry straight at `dist/main.js` so the match succeeds there, but a wrapper-based install (Nix, a shim, a generated `.cmd`) never can, because a script that *invokes* a file does not resolve *to* it.
Per [ADR 0019](.claude/adr/0019-hook-records-search-path-name.md), `setup hooks` therefore resolves `gitea-axi` on `PATH` itself and hands *that* location over, which makes the match succeed and the bare name get recorded.
It only accepts a `PATH` entry that resolves to the running entrypoint — a symlink to it, or a wrapper naming it in its text — so a same-named binary that is some other program falls through to the absolute-path fallback, as does a name that is not on `PATH` at all.
It reads `PATH` from `process.env`, not from the injected `deps.env`, because it has to agree with the SDK's own probing — so a test that wants to steer this has to set `process.env.PATH`.
Verify hook behaviour by driving the installed binary and reading `~/.claude/settings.json`, not by reading the SDK's interface; a hook whose recorded path no longer exists does not run and does not warn.
The SDK's `isManagedHook` recognises its own hook by testing whether the recorded command *contains* the marker, so it appends a duplicate instead of updating in place whenever the recorded command lacks the substring `gitea-axi`.
`setup hooks` compensates by pruning duplicates itself after the SDK writes.
This is why `package.nix` no longer needs to rename its build tree: the fast tier runs from `/build/source`, whose path has no marker in it, and the idempotency test passes there.

106
INSTALL.md Normal file
View File

@@ -0,0 +1,106 @@
# Installing gitea-axi
gitea-axi is a CLI plus two pieces of *ambient context* it installs for your agent:
- an **Agent Skill**, which teaches the agent to reach for gitea-axi instead of `tea`, raw API calls, or improvised `git`;
- a **SessionStart hook**, which renders the repository dashboard at the start of every agent session.
Installing the binary and installing that context are separate steps, and the second one has two paths.
Which path you want depends on who owns `~/.claude`.
## Installing the binary
### npm
```sh
npm install -g gitea-axi
```
Nothing has been published to the registry yet, so this works only once the first release lands — see [PUBLISHING.md](PUBLISHING.md).
Until then, the Nix path below and a local `npm pack` are the working ones.
### Nix
The flake exposes the package as `packages.<system>.gitea-axi`, with `default` as an alias.
```sh
nix run git+https://git.alexion.dev/alexion/gitea-axi -- --help
```
To install it from a system configuration, add the flake as an input and put `gitea-axi.packages.${system}.default` in `environment.systemPackages`.
Point the flake's `nixpkgs` input at your own to deduplicate.
gitea-axi shells out to `git` and to `tea` — the latter for credential discovery, per [ADR 0001](.claude/adr/0001-diff-auth-via-tea-login-list.md).
The Nix package wraps the binary so both are reachable without your installing them, while still preferring your own where you have them ([ADR 0018](.claude/adr/0018-nix-wrapper-defers-to-operator-binaries.md)).
## Installing the ambient context
### The `setup` command, if you own your agent configuration
```sh
gitea-axi setup # the Agent Skill
gitea-axi setup hooks # and the SessionStart hook
```
Both are idempotent, and there is no postinstall script — installation is always explicit ([ADR 0009](.claude/adr/0009-setup-command-over-postinstall.md)).
`setup hooks` covers three agents: Claude Code, Codex, and OpenCode.
This is the right path when the files under `~/.claude` (and `~/.codex`, and `~/.config/opencode`) are yours to write.
### The home-manager module, if your configuration owns them
If your agent configuration is generated declaratively, `setup` cannot write to it — the targets are read-only, and gitea-axi reports that rather than failing obscurely.
Use the module instead ([ADR 0020](.claude/adr/0020-home-manager-module-for-declarative-context.md)).
```nix
{
inputs.gitea-axi.url = "git+https://git.alexion.dev/alexion/gitea-axi";
inputs.gitea-axi.inputs.nixpkgs.follows = "nixpkgs";
}
```
```nix
{
imports = [ inputs.gitea-axi.homeModules.default ];
programs.claude-code.enable = true;
programs.gitea-axi.enable = true;
}
```
`programs.gitea-axi.enable` installs the CLI, always.
The Claude Code SessionStart hook follows the harness: it is declared under `programs.gitea-axi.enableClaudeCodeIntegration`, on by default, and lands only when `programs.claude-code.enable` is also on.
Enable gitea-axi on a host without Claude Code and you get the CLI and nothing else — no assertion, no toggles to turn off.
Importing the module without setting `enable` changes nothing at all.
The hook is declared through `programs.claude-code`'s own settings option, so a configuration that already sets its own `settings.hooks.SessionStart` gets gitea-axi's merged in alongside rather than colliding.
The Agent Skill is exposed as `packages.<system>.gitea-axi-skill` for project-local delivery through an agent-agnostic dev-shell helper.
#### Options
| Option | Default | Meaning |
| --- | --- | --- |
| `programs.gitea-axi.enable` | `false` | Install the CLI. |
| `programs.gitea-axi.package` | your `pkgs`' build of the package | The package to install, or `null` to declare the hook without the binary. |
| `programs.gitea-axi.enableClaudeCodeIntegration` | `true` | Declare the SessionStart hook when Claude Code is enabled. |
`package = null` declares the hook without installing the binary — for instance when you install gitea-axi system-wide through `environment.systemPackages`.
The hook records the bare name `gitea-axi` and lets `PATH` resolve it ([ADR 0019](.claude/adr/0019-hook-records-search-path-name.md)), so a binary installed anywhere on `PATH` satisfies it.
Turn `enableClaudeCodeIntegration` off to keep the CLI declarative while writing the hook yourself with `setup`.
#### What the module does not cover
Only the Claude Code SessionStart hook is declarative.
The Codex and OpenCode files that `setup hooks` also writes have no home-manager module owning them, so gitea-axi does not write them declaratively either.
On a declarative system those targets are unmanaged and therefore writable, so `gitea-axi setup hooks` still installs them.
The Agent Skill is packaged for project-local delivery, not installed globally by this module.
## Verifying an install
```sh
gitea-axi --help
gitea-axi # the dashboard, from inside a Gitea repository
```
A SessionStart hook takes effect on the next agent session, not the current one.

View File

@@ -28,7 +28,8 @@ Bump the version first with `npm version <patch|minor|major>`, which updates `pa
## Verifying the packed artifact
The packaging smoke test packs the real tarball, installs it globally into a throwaway prefix, and drives the installed binary — `--help`, the dashboard header, and `setup` finding the bundled skill:
The packaging smoke test comes in two facets: one asserts the shape of the packed tarball and its manifest, the other drives an installed binary — `--help`, the dashboard header, and `setup` finding the bundled skill.
By default it packs the real tarball and installs it globally into a throwaway prefix to produce that binary:
```sh
npm run test:pack
@@ -36,3 +37,10 @@ npm run test:pack
It builds, packs, and fetches the runtime dependencies from the registry, so it is slower than the unit and integration tiers and is not part of the default `npm test` run.
Distribution touches no Gitea API, so this smoke test is the distribution analogue of the live-Gitea end-to-end tier.
The two facets are `test/packaging/tarball.test.ts`, which is npm-specific by nature, and `test/packaging/installed-binary.test.ts`, which asserts what any *installed* gitea-axi must do, whatever installed it.
That second facet is therefore also the check a non-npm installation path runs against its own output.
Set `GITEA_AXI_INSTALLED_BIN` to the path of an already-installed binary to have it drive that one and skip the pack-and-install setup.
The Nix build is that other caller: its `installCheckPhase` points this variable at the wrapped binary it has just installed and runs the facet alone via `npm run test:installed`.
So `nix build` and `npm run test:pack` guarantee the same things about an installed gitea-axi, and neither can quietly weaken while the other holds.

View File

@@ -1,28 +1,39 @@
# Benchmark harness
This directory holds the benchmark that tests gitea-axi's central claim — that it is an agent-ergonomic, low-token interface to Gitea — against the `tea` CLI, the official `gitea-mcp` server, and raw Gitea REST calls.
This run bears that out on cost: gitea-axi posts the lowest cost-equivalent tokens and the lowest imputed cost of the four tools, though `gitea-mcp` edges it slightly on accuracy.
The result is that gitea-axi has reached the token floor: it is the cheapest of the structured interfaces by a wide margin and now runs neck-and-neck with hand-rolled raw REST — within ~1% overall — at 100% task success and the fewest turns of any arm.
Keeping the raw-REST arm in the comparison is deliberate: a benchmark of agent-CLIs that omits the hand-rolled baseline will always flatter the wrapper, and this one refuses to — which is exactly what makes gitea-axi matching that baseline meaningful.
## How it works
Each arm is an agent given exactly one of the four tools and nothing else, run on the same fixed model at temperature zero, so the comparison measures the tool rather than the model.
The suite is 20 tasks across four tiers — read, single-mutation, find-then-act, and multi-step — each run against a freshly seeded throwaway repository and scored deterministically by diffing the resulting repository state (or matching required facts in the agent's answer) against the seeded ground truth.
The headline metric is cost-equivalent tokens: the four token components (fresh input, cache write, cache read, output) weighted by Anthropic's published API pricing ratios, which is why an arm can spend more raw tokens yet cost less.
Every arm is credentialed the way its product is really configured — the token in its environment (`gitea-axi`, `gitea-mcp`) or in its prompt (`raw-api`), and a `tea` login for `tea` — so no arm pays a turn tax rediscovering how to authenticate.
## Results
| arm | cost-equivalent tokens | raw tokens | success | imputed cost |
| --- | ---: | ---: | ---: | ---: |
| gitea-axi | 16,921 | 68,093 | 95% | $6.20 |
| raw-api | 17,773 | 55,631 | 95% | $6.64 |
| gitea-mcp | 17,898 | 60,028 | 97% | $6.82 |
| tea | 20,505 | 80,702 | 90% | $7.25 |
| arm | cost-equivalent tokens | raw tokens | turns | success | imputed cost |
| --- | ---: | ---: | ---: | ---: | ---: |
| raw-api | 17,613 | 60,384 | 5.0 | 100% | ~$0.11 |
| gitea-axi | 17,815 | 62,705 | 4.8 | 100% | ~$0.11 |
| gitea-mcp | 23,198 | 76,378 | 5.2 | 100% | ~$0.15 |
| tea | 23,210 | 94,600 | 7.2 | 85% | ~$0.14 |
All four arms completed the full matrix — 20 of 20 tasks each, at the reporting floor.
gitea-axi wins on cost-equivalent tokens and on real imputed cost even though it does not use the fewest raw tokens: its interactions are output-light, and output is the most expensive component (weighted 5×), so its compact answers beat arms that emit more.
gitea-mcp is the most accurate at 97% against gitea-axi's 95%, so the two leaders trade a small accuracy edge for a clear cost lead.
All four arms completed the full matrix — 20 of 20 tasks each — and success is near-perfect: only `tea` slips, to 85% overall (67% on find-then-act), while the other three pass every run.
By tier, the read tasks are the hardest for every arm (7583% success) — exact-answer reads, not mutations, are where correctness slips.
tea is the outlier on find-then-act, dropping to 78% success at about 1.7× the cost-equivalent tokens of the other three arms.
Raw REST posts the lowest cost-equivalent tokens, but only barely: gitea-axi lands within ~1% of it (17,815 vs 17,613), a gap well inside the noise of three trials.
Direct HTTP with the token in the request header is the token floor no wrapper is supposed to undercut — and a structured tool drawing level with it is the headline of this snapshot.
_Snapshot: 2026-07-17 — 4 arms × 20 tasks × 3 trials each (240 samples), a single run against one live Gitea host; imputed cost is Anthropic API-priced._
gitea-axi is the cheapest structured interface by a wide margin — roughly 23% under both the official `gitea-mcp` server and the `tea` CLI — and it beats both of them on every tier, at 100% success.
It also takes the fewest turns of any arm (4.8, raw REST included) and the lowest output-token count of the shell arms; its compact TOON answers are what let a structured tool run this close to the floor.
By tier the picture is sharper than the overall total.
gitea-axi is the *cheapest arm outright* on the two discovery-heavy tiers — reads (13,294 vs raw's 14,656) and find-then-act (17,585 vs 18,631) — where finding the right entity is the work, and its compact search and list output beats reconstructing and parsing raw JSON.
Raw REST reclaims the lead on the mutation-heavy tiers — narrowly on single-mutation (15,212 vs 15,552), more clearly on multi-step (22,643 vs 26,079) — where the task is a handful of terse POSTs that no wrapper undercuts.
So the two trade tiers: gitea-axi wins where an interface earns its keep, raw REST wins where the request was already minimal.
Cost parity on the scored suite also understates gitea-axi, because the suite is the subset every arm can do at all.
The bonus table records capability-asymmetric operations — full-text issue search, rendering a PR's diff and checks, issue dependencies — that gitea-axi handles directly and raw REST has no first-class equivalent for.
_Snapshot: 2026-07-19 — 4 arms × 20 tasks × 3 trials each (240 samples), a single clean run with all four arms executed together against one live Gitea host; imputed cost is the mean per-task Anthropic-API-priced dollar cost._

View File

@@ -112,6 +112,32 @@ describe("buildArm", () => {
expect(envValues).toContain("s3cr3t-token");
});
// Behavior: the gitea-axi arm's shell is handed a credential environment
// carrying the host and token from the shared access, so its tool is
// pre-authenticated without the agent having to discover credentials
// (benchmark-harness spec, "Scaffolding"). The access here is built from
// independent literals; shell.env must deep-equal exactly the two facts echoed
// back under their env-var names (host→GITEA_AXI_API_URL, token→GITEA_AXI_TOKEN),
// and nothing else. The keys and mapping are fixed by gitea-axi's own env
// contract, not recomputed from arm.ts.
it("gives the gitea-axi arm's shell a credential env with the shared host URL and token", () => {
const preAuthed: SharedContext = {
coords: { owner: "acme", repo: "bench-xyz" },
access: { apiUrl: "https://git.example.test", token: "tok-abc123" },
};
const definition = buildArm("gitea-axi", preAuthed, { binRoot, locate });
const shell = definition.shell;
expect(shell).not.toBeNull();
if (shell === null) return;
expect(shell.env).toEqual({
GITEA_AXI_API_URL: "https://git.example.test",
GITEA_AXI_TOKEN: "tok-abc123",
});
});
// Behavior: each non-MCP arm's tool/PATH configuration comes from the guard and
// exposes only that arm's allowed binary (benchmark-harness spec, "Tool
// isolation" / ADR 0016). The (arm, binary) pairs are independent literals —

View File

@@ -40,6 +40,17 @@ export interface ArmShell {
path: string;
/** The authoritative tool-isolation guard, bound to this arm. */
guard: (command: string) => GuardDecision;
/**
* Credential environment the arm's tool is pre-configured with, merged into
* the agent's shell environment on top of {@link path}. This keeps the arms
* symmetric on authentication: every arm is handed its host and token the way
* its product is really configured, so none pays a turn tax rediscovering how
* to authenticate. The gitea-mcp arm gets the equivalent through its MCP
* server's env; raw-api uses the token stated in its prompt directly; the
* gitea-axi arm is configured through its own env interface here. Empty for an
* arm that needs no ambient credentials.
*/
env: Record<string, string>;
}
/**
@@ -172,7 +183,7 @@ function mcpAttachment(context: SharedContext): ArmMcp {
* gitea-mcp arm has no shell binary (`provisionArmBin` exposes nothing for it),
* so this returns null there and the arm reaches Gitea through its MCP tools.
*/
function buildShell(arm: Arm, options: BuildArmOptions): ArmShell | null {
function buildShell(arm: Arm, context: SharedContext, options: BuildArmOptions): ArmShell | null {
if (arm === "gitea-mcp") {
return null;
}
@@ -183,16 +194,37 @@ function buildShell(arm: Arm, options: BuildArmOptions): ArmShell | null {
binDir,
path: ambient === "" ? binDir : `${binDir}${delimiter}${ambient}`,
guard: (command) => guardCommand(arm, command),
env: shellEnv(arm, context),
};
}
/**
* The credential environment a shell arm's tool is pre-configured with. The
* gitea-axi arm is handed its host and token through its own env interface
* (`GITEA_AXI_API_URL` / `GITEA_AXI_TOKEN`), the symmetric counterpart to the
* gitea-mcp arm's server env: both name the same host and token, and both leave
* the agent to name the repository per call (gitea-axi via `-R`, gitea-mcp via
* each tool's arguments). The tea and raw-api arms need no ambient credentials —
* raw-api uses the token stated in its prompt directly in each request, and tea
* resolves its own login store — so their env is empty.
*/
function shellEnv(arm: Arm, context: SharedContext): Record<string, string> {
if (arm === "gitea-axi") {
return {
GITEA_AXI_API_URL: context.access.apiUrl,
GITEA_AXI_TOKEN: context.access.token,
};
}
return {};
}
/** Assemble the single arm definition the runner consumes for the given arm. */
export function buildArm(arm: Arm, context: SharedContext, options: BuildArmOptions): ArmDefinition {
const systemPrompt = `${basePrompt(context)}\n\n${armBootstrap(arm, context, options)}`;
return {
arm,
systemPrompt,
shell: buildShell(arm, options),
shell: buildShell(arm, context, options),
mcp: arm === "gitea-mcp" ? mcpAttachment(context) : null,
};
}

View File

@@ -12,17 +12,17 @@
// runner (runner.ts) drives the run and feeds the transcript here.
import type { ArmDefinition } from "./arm.js";
import type { TranscriptEntry } from "./result.js";
/**
* One tool invocation recorded in the agent's transcript, reduced to what the
* isolation audit needs. `shell` is a proposed shell command; `mcp` is a call to
* an attached MCP server's tool; `other` is a built-in, non-Gitea-reaching tool
* (file read/edit and the like) that carries no isolation risk.
* (file read/edit and the like) that carries no isolation risk. This is the same
* shape the record persists ({@link TranscriptEntry}); the audit and the record
* share one type so they cannot drift.
*/
export type ToolUse =
| { kind: "shell"; command: string }
| { kind: "mcp"; server: string; tool: string }
| { kind: "other"; name: string };
export type ToolUse = TranscriptEntry;
/**
* The audit's verdict. On a leak it carries a human-readable reason per foreign

View File

@@ -310,6 +310,94 @@ describe("checkReadAnswer", () => {
expect(checkReadAnswer(facts, report)).toEqual({ pass: true });
});
it("passes when the report wraps a fact's phrasing in markdown emphasis", () => {
// The fact's acceptable phrasing is the bare substring "5 open".
const facts: RequiredFact[] = [
{ description: "open-issue count", anyOf: ["5 open"] },
];
// The report is correct but renders the number in markdown bold. Emphasis
// markers (`*`, `_`, backtick) are formatting, not substance, so "**5**" is
// equivalent to "5" and the phrasing "5 open" is present.
const report = "There are **5** open issues in the repository.";
expect(checkReadAnswer(facts, report)).toEqual({ pass: true });
});
it("fails when a markdown-formatted report states the wrong value", () => {
// The only acceptable phrasing counts five open issues.
const facts: RequiredFact[] = [
{ description: "open-issue count", anyOf: ["5 open"] },
];
// The report is markdown-formatted but substantively wrong: it counts three,
// not five. Stripping emphasis must only remove formatting, so after stripping
// ("there are 3 open issues") the phrasing "5 open" is still absent.
const report = "There are **3** open issues in the repository.";
const result = checkReadAnswer(facts, report);
expect(result.pass).toBe(false);
});
it("passes via pattern when filler words break every contiguous anyOf phrase", () => {
// A count fact is brittle when pinned to fixed phrases: a human padding the
// answer with filler words ("issues are currently") splits any contiguous
// rendering. The optional `pattern` — a case-insensitive regex source — spans
// that filler as a bounded run of alphabetic words between the number and
// "open", so the fact is satisfied even though no `anyOf` phrase appears verbatim.
const facts: RequiredFact[] = [
{
description: "count of open issues",
anyOf: ["5 open", "5 issues are open"],
pattern: "\\b5(?: [a-z]+){0,4} open\\b",
},
];
// "issues are currently" is inserted between "5" and "open", so neither
// contiguous anyOf phrase matches — but the pattern's alphabetic filler run does.
const report = "5 issues are currently open (5 of 5 total).";
expect(checkReadAnswer(facts, report)).toEqual({ pass: true });
});
it("fails when a digit interrupts the number-to-open run, so a wrong count cannot slip through", () => {
// The same pattern-bearing fact as above. Here the report states a DIFFERENT
// open count (3), merely mentioning the number 5 elsewhere. The pattern's
// filler run is alphabetic only, so the digit "3" between the matched "5" and
// "open" is not spanned, and no anyOf phrase matches either — the fact fails.
const facts: RequiredFact[] = [
{
description: "count of open issues",
anyOf: ["5 open", "5 issues are open"],
pattern: "\\b5(?: [a-z]+){0,4} open\\b",
},
];
// "5 issues in total, and 3 open" — the 5 is a total, the open count is 3.
const report = "There are 5 issues in total, and 3 open.";
const result = checkReadAnswer(facts, report);
expect(result.pass).toBe(false);
// The unmet fact must remain identifiable by its own description.
if (result.pass === false) {
expect(result.differences.some((d) => d.includes("count of open issues"))).toBe(true);
}
});
it("still matches on anyOf alone when a fact carries no pattern", () => {
// A fact without `pattern` behaves exactly as before: only the contiguous
// anyOf renderings are consulted, unaffected by the new pattern support.
const facts: RequiredFact[] = [
{ description: "count of open issues", anyOf: ["7 open issues", "seven open issues"] },
];
const report = "The board shows 7 open issues right now.";
expect(checkReadAnswer(facts, report)).toEqual({ pass: true });
});
});
describe("score", () => {

View File

@@ -241,9 +241,7 @@ function matchByKey<T>(
*/
export function checkReadAnswer(facts: RequiredFact[], report: string): CheckResult {
const haystack = normalizeText(report);
const missing = facts.filter(
(fact) => !fact.anyOf.some((rendering) => haystack.includes(normalizeText(rendering))),
);
const missing = facts.filter((fact) => !factPresent(fact, haystack));
if (missing.length === 0) {
return { pass: true };
}
@@ -253,9 +251,32 @@ export function checkReadAnswer(facts: RequiredFact[], report: string): CheckRes
};
}
/** Lower-case and collapse runs of whitespace so incidental phrasing does not matter. */
/**
* Whether a required fact is present in the normalized report: any `anyOf`
* rendering as a contiguous substring, or the optional `pattern` regex matching.
* The pattern is compiled case-insensitively over the already-normalized text, so
* it recognises a count padded with filler ("5 issues are currently open") that no
* fixed phrase would.
*/
function factPresent(fact: RequiredFact, haystack: string): boolean {
if (fact.anyOf.some((rendering) => haystack.includes(normalizeText(rendering)))) {
return true;
}
return fact.pattern !== undefined && new RegExp(fact.pattern, "i").test(haystack);
}
/**
* Lower-case, drop markdown emphasis/code markers, and collapse runs of
* whitespace so incidental phrasing and formatting do not matter — a report that
* bolds a value (`**5**`) matches a phrasing that does not (`5 open`), since the
* emphasis is presentation, not substance.
*/
function normalizeText(text: string): string {
return text.toLowerCase().replace(/\s+/g, " ").trim();
return text
.toLowerCase()
.replace(/[*_`]/g, "")
.replace(/\s+/g, " ")
.trim();
}
/**

View File

@@ -45,6 +45,19 @@ export type FailureTag = "incorrect" | "confused" | "hung";
/** The pass/fail outcome of a run, tagged with the failure mode when it fails. */
export type Outcome = { pass: true } | { pass: false; failure: FailureTag };
/**
* One tool invocation as it is recorded in a run's transcript, in the order it
* executed. This is the canonical shape the harness both audits for isolation
* (see audit.ts, whose `ToolUse` aliases this) and persists on the record for
* diagnosis. A `shell` entry keeps the exact command line the agent ran; an `mcp`
* entry names the server and tool it called; `other` names a built-in tool that
* reaches no Gitea channel.
*/
export type TranscriptEntry =
| { kind: "shell"; command: string }
| { kind: "mcp"; server: string; tool: string }
| { kind: "other"; name: string };
/**
* One completed `(arm, task, trial)` run. Carries the metrics the headline and
* supporting views are computed from, plus the tags those views group by.
@@ -72,6 +85,24 @@ export interface ResultRecord {
/** Pass/fail outcome with a failure tag. */
outcome: Outcome;
/**
* The agent's final report, retained for read tasks so a failed read is
* diagnosable directly from the record — the exact text the agent submitted,
* distinguishing a wrong answer from a right one phrased in words the checker's
* accepted-phrasing list did not match. Absent for mutation tasks, which are
* scored by diffing repository state and have no agent report to record.
*/
report?: string;
/**
* The ordered transcript of tool invocations the run made, retained on every
* scored run so its turn cost is diagnosable directly from the record — the
* exact command sequence, which is how an arm's turn count (the dominant driver
* of cache-read tokens) is explained. Absent only for a hung run, which
* produced no completed transcript to record.
*/
transcript?: TranscriptEntry[];
}
/**

View File

@@ -57,6 +57,8 @@ export interface RunArgs {
turnCap: number;
wallClockMs: number;
storeRoot: string;
/** Optional bundled-skill override for the gitea-axi arm; defaults to the shipped SKILL.md. */
skillPath?: string;
}
/** The parse outcome: a request for help, or a resolved configuration to run. */
@@ -71,6 +73,7 @@ const KNOWN_FLAGS = new Set([
"turn-cap",
"wall-clock-ms",
"store",
"skill",
]);
/** A usage error, surfaced to the maintainer with the offending detail. */
@@ -157,6 +160,7 @@ export function parseRunArgs(
turnCap,
wallClockMs,
storeRoot: flags.get("store") ?? DEFAULT_STORE_ROOT,
...(flags.has("skill") ? { skillPath: flags.get("skill") } : {}),
};
}
@@ -180,6 +184,7 @@ Options:
--turn-cap <n> Per-run turn cap (default: ${DEFAULT_TURN_CAP})
--wall-clock-ms <n> Per-run wall-clock backstop in ms (default: ${DEFAULT_WALL_CLOCK_MS})
--store <dir> Sample store root (default: ${DEFAULT_STORE_ROOT})
--skill <path> Override the gitea-axi arm's bundled skill (default: shipped SKILL.md)
-h, --help Show this help`;
/** Render the run-loop tally into the lines printed after a sitting. */
@@ -246,7 +251,7 @@ export async function runBenchCommand(
driver: sdkAgentDriver(),
store,
bounds: { turnCap: parsed.turnCap, wallClockMs: parsed.wallClockMs },
build: { binRoot },
build: { binRoot, ...(parsed.skillPath !== undefined ? { skillPath: parsed.skillPath } : {}) },
});
for (const line of summarize(result, parsed.storeRoot)) {
out(line);

View File

@@ -6,6 +6,7 @@ import type { RepoState } from "./scoring-spec.js";
import type { BenchAccess, RepoCoords } from "./seed.js";
import { groundTruth } from "./seed-plan.js";
import { createSampleStore } from "./store.js";
import type { BenchTask } from "./task.js";
import { SAMPLE_TASK } from "./task.js";
import type { AgentDriver, BenchHost } from "./runner.js";
import { runCell } from "./runner.js";
@@ -240,6 +241,12 @@ describe("runCell", () => {
expect(sample.imputedCostUsd).toBe(DRIVER_COST);
expect(sample.outcome).toEqual({ pass: true });
// The recorded sample carries the run's tool transcript — the exact ordered
// sequence of tool invocations the driver reported — so the turn's cost is
// diagnosable directly from the record. The expected value is the literal the
// fake driver planted, deep-equal and in order, not recomputed from runner.ts.
expect(sample.transcript).toEqual([{ kind: "mcp", server: "gitea-mcp", tool: "edit_issue" }]);
// The sample carries the run's wall-clock duration; a completed run takes
// non-negative time.
expect(typeof sample.durationMs).toBe("number");
@@ -397,4 +404,110 @@ describe("runCell", () => {
if (sample === undefined) return;
expect(sample.outcome).toEqual({ pass: false, failure: "incorrect" });
});
// Behavior: a completed read cell produces a record whose report is the agent's
// final report (benchmark-harness spec, record-assembly seam). A read task is
// scored by matching the agent's final report against required facts; the
// recorded ResultRecord must carry that final report in its `report` field so a
// read is diagnosable from the record alone. Here an inline read task's fact is
// satisfied by the driver's finalReport (it contains "5 open"), so the cell
// completes as a pass; the recorded sample's `report` must equal the exact
// planted finalReport literal — an independent literal, read back through the
// store, not recomputed from runner.ts.
it("records the agent's final report on a completed read cell", async () => {
const READ_TASK: BenchTask = {
id: "count-open-issues",
tier: "read",
intent: "How many issues are open?",
scoringSpec: () => ({
kind: "read",
facts: [{ description: "open-issue count", anyOf: ["5 open"] }],
}),
};
// The independent literal we plant as the driver's final report. It contains
// the fact's rendering ("5 open"), so the read scorer scores it a pass.
const FINAL_REPORT = "There are 5 open issues.";
const readDriver: AgentDriver = {
async run() {
return {
tokens: { freshInput: 50, cacheCreation: 0, cacheRead: 0, output: 10 },
turns: 2,
imputedCostUsd: 0.03,
transcript: [{ kind: "mcp", server: "gitea-mcp", tool: "list_repo_issues" }],
finalReport: FINAL_REPORT,
stoppedByTurnCap: false,
};
},
};
const { host } = createFakeHost();
const store = createSampleStore(storeRoot);
const outcome = await runCell({
arm: "gitea-mcp",
task: READ_TASK,
trial: 1,
access: ACCESS,
host,
driver: readDriver,
store,
bounds: { turnCap: 10, wallClockMs: 60_000 },
build: { binRoot },
});
expect(outcome.kind).toBe("recorded");
if (outcome.kind !== "recorded") return;
const samples = store.read({ arm: "gitea-mcp", taskId: READ_TASK.id });
expect(samples).toHaveLength(1);
const [sample] = samples;
expect(sample).toBeDefined();
if (sample === undefined) return;
// A completed read cell: the checker scored the final report a pass.
expect(sample.outcome).toEqual({ pass: true });
// The record carries the agent's final report verbatim.
expect(sample.report).toBe(FINAL_REPORT);
});
// Behavior: a completed mutation cell produces a record WITHOUT an agent report
// (benchmark-harness spec, record-assembly seam). A mutation task is scored by
// diffing repository state, not by reading the agent's words, so the recorded
// ResultRecord must carry no `report` field at all — absent, not present-but-
// undefined. SAMPLE_TASK is a mutation task; paired with the passing fake host
// and driver it drives a completed mutation PASS. The absence is the independent
// source of truth (the acceptance criterion), read back through the store after
// the JSON round-trip so an absent field is genuinely not a key.
it("records no agent report on a completed mutation cell", async () => {
const { host } = createFakeHost();
const store = createSampleStore(storeRoot);
const outcome = await runCell({
arm: "gitea-mcp",
task: SAMPLE_TASK,
trial: 1,
access: ACCESS,
host,
driver,
store,
bounds: { turnCap: 10, wallClockMs: 60_000 },
build: { binRoot },
});
expect(outcome.kind).toBe("recorded");
if (outcome.kind !== "recorded") return;
const samples = store.read({ arm: "gitea-mcp", taskId: SAMPLE_TASK.id });
expect(samples).toHaveLength(1);
const [sample] = samples;
expect(sample).toBeDefined();
if (sample === undefined) return;
// A completed mutation cell: the state diff scored a pass.
expect(sample.outcome).toEqual({ pass: true });
// A mutation record carries no agent report — the key is absent entirely.
expect(sample).not.toHaveProperty("report");
});
});

View File

@@ -14,8 +14,8 @@
import { buildArm, type ArmDefinition, type BuildArmOptions, type SharedContext } from "./arm.js";
import { auditTranscript, type ToolUse } from "./audit.js";
import { score } from "./checker.js";
import type { Arm, Outcome, ResultRecord, TokenComponents } from "./result.js";
import type { RepoState } from "./scoring-spec.js";
import type { Arm, Outcome, ResultRecord, TokenComponents, TranscriptEntry } from "./result.js";
import type { RepoState, ScoringSpec } from "./scoring-spec.js";
import type { BenchAccess, RepoCoords } from "./seed.js";
import type { SampleStore } from "./store.js";
import type { BenchTask } from "./task.js";
@@ -149,7 +149,10 @@ export async function runCell(input: RunCellInput): Promise<CellOutcome> {
// A hung run produced no completed transcript to audit or score; record it
// as a failure with no measured consumption.
if (result.kind === "hung") {
return recorded(store, makeRecord(input, NO_TOKENS, 0, 0, durationMs, { pass: false, failure: "hung" }, clock));
return recorded(
store,
makeRecord(input, NO_TOKENS, 0, 0, durationMs, { pass: false, failure: "hung" }, undefined, undefined, clock),
);
}
const run = result.run;
@@ -161,13 +164,19 @@ export async function runCell(input: RunCellInput): Promise<CellOutcome> {
return { kind: "invalid", leaks: audit.leaks };
}
const spec = task.scoringSpec(coords.owner);
const outcome = run.stoppedByTurnCap
? ({ pass: false, failure: "confused" } as const)
: await scoreRun(host, coords, task, run);
: await scoreRun(host, coords, spec, run);
// Retain the agent's final report for read tasks so a failed read is
// diagnosable directly from the record; mutation tasks are scored by diffing
// repository state and have no agent report to record.
const report = spec.kind === "read" ? run.finalReport : undefined;
return recorded(
store,
makeRecord(input, run.tokens, run.turns, run.imputedCostUsd, durationMs, outcome, clock),
makeRecord(input, run.tokens, run.turns, run.imputedCostUsd, durationMs, outcome, report, run.transcript, clock),
);
} finally {
await host.delete(coords);
@@ -214,8 +223,7 @@ async function runBounded(
* final report against the required facts for a read. A pass is a pass; anything
* the checker rejects is an incorrect failure.
*/
async function scoreRun(host: BenchHost, coords: RepoCoords, task: BenchTask, run: AgentRun): Promise<Outcome> {
const spec = task.scoringSpec(coords.owner);
async function scoreRun(host: BenchHost, coords: RepoCoords, spec: ScoringSpec, run: AgentRun): Promise<Outcome> {
const snapshot = await host.capture(coords);
const check =
spec.kind === "mutation"
@@ -232,6 +240,8 @@ function makeRecord(
imputedCostUsd: number,
durationMs: number,
outcome: Outcome,
report: string | undefined,
transcript: TranscriptEntry[] | undefined,
clock: RunnerClock,
): ResultRecord {
return {
@@ -245,6 +255,12 @@ function makeRecord(
durationMs,
imputedCostUsd,
outcome,
// Absent for mutation runs and runs with no completed report (hung); JSON
// serialization drops the key when undefined.
...(report !== undefined ? { report } : {}),
// Absent only for a hung run, which produced no transcript; JSON
// serialization drops the key when undefined.
...(transcript !== undefined ? { transcript } : {}),
};
}

View File

@@ -131,10 +131,21 @@ export interface RepoState {
* whitespace and case normalization), so a count or a name can be phrased
* variously without resorting to an LLM judge. `description` names the fact in
* diagnostics when it is missing.
*
* `anyOf` matches a *contiguous* substring, which is brittle for facts a human
* naturally pads with filler — "5 issues are currently open" does not contain
* the fixed phrase "5 issues are open". For those, supply `pattern`: a regular
* expression (matched against the same normalized report) that satisfies the
* fact when it matches, so the count itself can be recognised rather than one
* exact wording. A fact is present when any `anyOf` rendering *or* `pattern`
* matches; `anyOf` stays the human-readable renderings even when `pattern`
* carries the real matcher.
*/
export interface RequiredFact {
description: string;
anyOf: string[];
/** Optional regex (source, matched case-insensitively against the normalized report). */
pattern?: string;
}
/**

View File

@@ -1,5 +1,7 @@
import { readdirSync, rmSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { sumTokens } from "./sdk-driver.js";
import { createAgentWorkdir, sumTokens } from "./sdk-driver.js";
import type { SdkResultMessage } from "./sdk-driver.js";
describe("sumTokens", () => {
@@ -87,3 +89,31 @@ describe("sumTokens", () => {
});
});
});
describe("createAgentWorkdir", () => {
// Behavior: each agent run must operate in a fresh, empty directory located
// OUTSIDE the harness's own checkout. This isolation is what stops an agent
// that forgets an explicit `-R OWNER/NAME` from having its `gitea-axi`/`tea`
// tools silently default their target repo to the harness's own git checkout:
// a directory that is empty (no `.git`) and outside the current checkout gives
// those tools nothing local to resolve.
//
// The three assertions are independent anti-bug properties drawn directly from
// the requirement, not recomputed from the implementation:
// 1. the path exists and is a directory,
// 2. it is empty (zero entries — in particular no `.git`),
// 3. it sits outside the current working directory (relative path escapes
// upward with "..").
it("returns a fresh, empty directory located outside the current checkout", () => {
const dir = createAgentWorkdir();
try {
expect(statSync(dir).isDirectory()).toBe(true);
expect(readdirSync(dir)).toHaveLength(0);
expect(path.relative(process.cwd(), dir).startsWith("..")).toBe(true);
} finally {
// Safe: `dir` is a fresh throwaway temp dir we just received from
// createAgentWorkdir(); never a delete of cwd or any pre-existing path.
rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -17,6 +17,9 @@
// recorded in the transcript, so the runner's post-run audit sees what actually
// executed — a blocked attempt is realistic wasted effort, not a leak.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ArmDefinition } from "./arm.js";
import { foreignToolReason, type ToolUse } from "./audit.js";
import type { TokenComponents } from "./result.js";
@@ -104,6 +107,8 @@ interface SdkQueryOptions {
abortController: AbortController;
canUseTool: (toolName: string, input: Record<string, unknown>) => Promise<SdkPermissionResult>;
settingSources: string[];
/** The agent's shell working directory: a fresh empty dir outside any checkout. */
cwd: string;
env?: Record<string, string | undefined>;
mcpServers?: Record<string, SdkStdioServer>;
disallowedTools?: string[];
@@ -197,30 +202,47 @@ export function sdkAgentDriver(config: SdkDriverConfig = {}): AgentDriver {
return { behavior: "allow", updatedInput: toolInput };
};
const options = buildOptions(input.arm, model, input.turnCap, controller, canUseTool);
const workdir = createAgentWorkdir();
try {
const options = buildOptions(input.arm, model, input.turnCap, controller, canUseTool, workdir);
let result: SdkResultMessage | undefined;
for await (const message of query({ prompt: input.intent, options })) {
if (message.type === "result") {
result = message as SdkResultMessage;
let result: SdkResultMessage | undefined;
for await (const message of query({ prompt: input.intent, options })) {
if (message.type === "result") {
result = message as SdkResultMessage;
}
}
if (result === undefined) {
throw new Error("the Agent SDK produced no result message");
}
}
if (result === undefined) {
throw new Error("the Agent SDK produced no result message");
}
return {
tokens: sumTokens(result),
turns: result.num_turns ?? 0,
imputedCostUsd: result.total_cost_usd ?? 0,
transcript,
finalReport: result.result ?? "",
stoppedByTurnCap: result.subtype === "error_max_turns",
};
return {
tokens: sumTokens(result),
turns: result.num_turns ?? 0,
imputedCostUsd: result.total_cost_usd ?? 0,
transcript,
finalReport: result.result ?? "",
stoppedByTurnCap: result.subtype === "error_max_turns",
};
} finally {
rmSync(workdir, { recursive: true, force: true });
}
},
};
}
/**
* Create a fresh, empty working directory for one agent run, outside any git
* checkout. The agent's shell runs here so a shell tool that defaults its target
* repository from the local checkout (gitea-axi, tea) cannot silently resolve the
* harness's own repository when the agent omits an explicit `-R`; with no ambient
* checkout the agent must target the repository named in its prompt. The caller
* deletes it when the run ends.
*/
export function createAgentWorkdir(): string {
return mkdtempSync(join(tmpdir(), "bench-agent-cwd-"));
}
/** Assemble the SDK query options for an arm's tool configuration. */
function buildOptions(
arm: ArmDefinition,
@@ -228,6 +250,7 @@ function buildOptions(
turnCap: number,
controller: AbortController,
canUseTool: SdkQueryOptions["canUseTool"],
cwd: string,
): SdkQueryOptions {
const options: SdkQueryOptions = {
model,
@@ -241,11 +264,17 @@ function buildOptions(
// Start from a clean slate: no user/project settings leak tools or config
// into the measured run.
settingSources: [],
// Run outside any checkout so a forgotten -R cannot resolve the harness's own
// repository instead of the seeded throwaway (see createAgentWorkdir).
cwd,
};
if (arm.shell !== null) {
// Lead the agent's PATH with the arm's curated bin directory so only its one
// allowed binary resolves by name; the guard on canUseTool is the authority.
options.env = { ...process.env, PATH: arm.shell.path };
// Layer the arm's credential env underneath so its tool is pre-authenticated
// the way its product is really configured, symmetric to the gitea-mcp
// server's env (see ArmShell.env); PATH stays last so it is never overridden.
options.env = { ...process.env, ...arm.shell.env, PATH: arm.shell.path };
}
if (arm.mcp !== null) {
options.mcpServers = { [arm.arm]: { type: "stdio", ...arm.mcp.server } };

View File

@@ -359,12 +359,122 @@ async function ensurePullRequest(
await ensureReviews(access, coords, number, pr.reviews);
}
/** How long the readiness gate polls before giving up, and how often it re-checks. */
const READINESS_TIMEOUT_MS = 60_000;
const READINESS_INTERVAL_MS = 750;
/** Resolve after `ms` milliseconds. */
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** A search hit's minimal shape: the repository the matched issue/pull belongs to. */
interface SearchHit {
repository?: { name?: string } | null;
}
/**
* Whether the issue indexer has caught up: a full-text search for `title` of the
* given kind returns a hit in this repository. The endpoint spans every repo the
* owner can access (mirroring `gitea-axi search`), so a hit only counts when its
* repository matches. A non-2xx or a not-yet-indexed title reads as not-ready.
*/
async function searchIndexed(
access: BenchAccess,
coords: RepoCoords,
type: "issues" | "pulls",
title: string,
): Promise<boolean> {
const query = new URLSearchParams({ q: title, type, owner: coords.owner, state: "all", limit: "50" });
const res = await request(access, "GET", `/repos/issues/search?${query.toString()}`);
if (!res.ok) {
return false;
}
const hits = (await res.json()) as SearchHit[];
return hits.some((hit) => hit.repository?.name === coords.repo);
}
/**
* The reason a freshly seeded repository is not yet ready for the agent, or `null`
* when it is. Ready means an independent read sees the repository and its full
* seeded issue and pull spread, and the issue indexer returns a seeded issue and
* pull — the two consistency windows (repo visibility and index lag) an agent
* would otherwise race and fail against. Every read is non-throwing, so a
* transient error reads as not-ready and is retried rather than propagated.
*/
async function readinessGap(access: BenchAccess, coords: RepoCoords): Promise<string | null> {
const repoPath = `/repos/${coords.owner}/${coords.repo}`;
const repoRes = await request(access, "GET", repoPath);
if (!repoRes.ok) {
return `repository read returned ${repoRes.status}`;
}
const issuesRes = await request(access, "GET", `${repoPath}/issues?type=issues&state=all&limit=100`);
if (!issuesRes.ok) {
return `issue list returned ${issuesRes.status}`;
}
const issues = (await issuesRes.json()) as GiteaIssue[];
if (issues.length < SEED_PLAN.issues.length) {
return `only ${issues.length}/${SEED_PLAN.issues.length} issues visible`;
}
const pullsRes = await request(access, "GET", `${repoPath}/pulls?state=all&limit=100`);
if (!pullsRes.ok) {
return `pull list returned ${pullsRes.status}`;
}
const pulls = (await pullsRes.json()) as GiteaPull[];
if (pulls.length < SEED_PLAN.pullRequests.length) {
return `only ${pulls.length}/${SEED_PLAN.pullRequests.length} pull requests visible`;
}
const sampleIssue = SEED_PLAN.issues[0]!.title;
if (!(await searchIndexed(access, coords, "issues", sampleIssue))) {
return `issue "${sampleIssue}" not yet indexed for search`;
}
const samplePull = SEED_PLAN.pullRequests[0]!.title;
if (!(await searchIndexed(access, coords, "pulls", samplePull))) {
return `pull request "${samplePull}" not yet indexed for search`;
}
return null;
}
/**
* Block until a freshly seeded repository is fully consistent for the agent, or
* fail loudly if it never settles within the timeout. Gitea makes a just-created
* repository and its just-written issues and pulls visible to the seed's own
* writes immediately, but an independent reader — the agent, a fresh process
* moments later — can hit a brief 404 window on the repository and a longer lag on
* the async issue indexer. Both are the benchmark's races, not the tool's, so
* closing them here keeps a scored run measuring gitea-axi rather than host
* propagation. A timeout is a harness failure surfaced to the maintainer, never a
* scored agent failure.
*/
export async function waitForSeedReady(
access: BenchAccess,
coords: RepoCoords,
timeoutMs = READINESS_TIMEOUT_MS,
intervalMs = READINESS_INTERVAL_MS,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let gap = await readinessGap(access, coords);
while (gap !== null) {
if (Date.now() >= deadline) {
throw new Error(
`seeded repository ${coords.owner}/${coords.repo} not ready after ${timeoutMs}ms: ${gap}`,
);
}
await delay(intervalMs);
gap = await readinessGap(access, coords);
}
}
/**
* Seed a freshly provisioned repository to the ground truth, idempotently. Labels
* come first (so issues and pull requests can apply them), then the issue spread,
* then the pull requests. Returns the deterministic ground-truth RepoState the
* checker scores against; on a fresh repository the created numbers match it,
* and a re-run leaves them unchanged.
*
* Before returning, it waits out the repository-visibility and issue-indexer
* consistency windows (see waitForSeedReady), so the agent that runs next never
* races a repository that is not yet readable or searchable.
*/
export async function seedRepo(access: BenchAccess, coords: RepoCoords): Promise<RepoState> {
const user = await currentUser(access);
@@ -384,6 +494,7 @@ export async function seedRepo(access: BenchAccess, coords: RepoCoords): Promise
await ensurePullRequest(access, coords, pr, labelIds, pullsByTitle);
}
await waitForSeedReady(access, coords);
return groundTruth(user);
}

View File

@@ -102,4 +102,24 @@ describe("SampleStore", () => {
secondRun.append(second);
expect(secondRun.read(cell)).toEqual([first, second]);
});
// Behavior: the store round-trips a report-bearing record without any change to
// the store itself. A read task's record carries the agent's final report in the
// `report` field; appending it and reading it back must return it equal, `report`
// and all, proving the store persists the field with no store change. The planted
// report string is an independent literal, not anything production code computes.
it("round-trips a record carrying a report field, preserving it unchanged", () => {
const store = createSampleStore(root);
const report = "There are 5 open issues in the repository.";
const record = sample({ tier: "read", report });
store.append(record);
const readBack = store.read({ arm: record.arm, taskId: record.taskId });
expect(readBack).toEqual([record]);
const [only] = readBack;
expect(only).toBeDefined();
if (only === undefined) return;
expect(only.report).toBe(report);
});
});

View File

@@ -92,6 +92,11 @@ function readTasks(): BenchTask[] {
{
description: "the repository has 5 open issues",
anyOf: ["5 open", "five open", "open issues: 5", "open: 5", "5 issues are open"],
// "5" followed by "open" across up to four alphabetic filler words, so
// natural padding ("5 issues are currently open") is recognised while a
// digit between them (a wrong "5 issues, 3 open") is not: the filler run
// is alphabetic only, so it cannot span another count.
pattern: "\\b5(?: [a-z]+){0,4} open\\b",
},
]),
},

View File

@@ -0,0 +1,104 @@
# The first automated proof of the home-manager module's composition (ADR 0021).
#
# It evaluates the *real* module through home-manager's standalone configuration
# entry point and builds the resulting home files derivation — the file-linkage
# layer that actually decides whether two declarations collide — under several
# operator configurations, then asserts on the tree each one produces. It never
# reads module internals: not option values, not store paths, not the shape of
# the file mechanism, only which files a generation contains. Building the home
# files derivation needs neither the Claude Code binary nor a running agent.
#
# The configurations cover the remaining composition risks: the explicit
# sibling-enable gate that keeps the hook off a host without Claude Code, the
# hook merging into an operator's own SessionStart list rather than replacing it,
# and the Agent Skill staying out of global harness directories because project
# dev shells deliver it through the agent-agnostic skills helper.
{
pkgs,
home-manager,
module,
package,
}:
let
# A distinctive command so the merged-hook assertion can tell the operator's
# own SessionStart hook apart from gitea-axi's in the generated settings.json.
operatorHook = {
matcher = "";
hooks = [
{
type = "command";
command = "operator-own-session-hook";
}
];
};
# Evaluate the real module through home-manager's standalone entry point and
# return the home files derivation — the tree home-manager would link into
# $HOME. `programs.gitea-axi.enable` is on in every configuration; the package
# is the flake's own build, so the check reuses the store path the package
# check already produces rather than building a second time.
homeFiles =
operatorConfig:
(home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
module
{
home.username = "operator";
home.homeDirectory = "/home/operator";
home.stateVersion = "24.11";
programs.gitea-axi.enable = true;
programs.gitea-axi.package = package;
}
operatorConfig
];
}).config.home-files;
# Claude Code disabled: the sibling-enable gate in the Claude Code module must
# leave no settings file or Skill entry in the generation.
claudeCodeOff = homeFiles {
programs.claude-code.enable = false;
};
# Claude Code enabled: the module contributes only the SessionStart hook, not
# a global Skill entry.
claudeCodeOn = homeFiles {
programs.claude-code.enable = true;
};
# An operator with their own SessionStart hook: the module's hook must merge
# into that list rather than replace it.
mergedHook = homeFiles {
programs.claude-code.enable = true;
programs.claude-code.settings.hooks.SessionStart = [ operatorHook ];
};
in
pkgs.runCommandLocal "gitea-axi-home-manager-module-check"
{
# Forcing each derivation as a build input is what actually builds the home
# files tree under every configuration.
inherit
claudeCodeOff
claudeCodeOn
mergedHook
;
}
''
echo "Claude Code disabled: no hook settings or global Skill entry is written"
test ! -e "$claudeCodeOff/.claude/settings.json"
test ! -e "$claudeCodeOff/.claude/skills/gitea-axi"
echo "Claude Code enabled: hook lands, but the Skill is not globally installed"
grep -q '"gitea-axi"' "$claudeCodeOn/.claude/settings.json"
test ! -e "$claudeCodeOn/.claude/skills/gitea-axi"
echo "operator's own SessionStart hook: the module's hook merges in"
# Match the commands as quoted JSON string values, not by their position or
# the emitter's colon spacing: both must be present for a merge (rather than
# a replacement) of the two SessionStart hooks.
grep -q '"operator-own-session-hook"' "$mergedHook/.claude/settings.json"
grep -q '"gitea-axi"' "$mergedHook/.claude/settings.json"
touch "$out"
''

48
flake.lock generated Normal file
View File

@@ -0,0 +1,48 @@
{
"nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784588016,
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784356753,
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

128
flake.nix Normal file
View File

@@ -0,0 +1,128 @@
{
description = "Agent-ergonomic CLI for Gitea issues and pull requests";
# Tracks unstable to match the maintainer's system. Consumers deduplicate by
# pointing this input at their own nixpkgs, so it governs standalone builds
# only — never the deployed artifact.
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# Present only so `nix flake check` can evaluate the home-manager module
# against real home-manager (the `home-manager-module` check). Its nixpkgs
# follows this flake's, so the module is checked against the same
# nixpkgs-and-home-manager pairing a consumer following this flake would get.
# It has no bearing on the package or the module a consumer imports — the
# module is a bare function that takes the importing configuration's own
# home-manager and pkgs.
inputs.home-manager.url = "github:nix-community/home-manager";
inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs";
outputs =
{ self, nixpkgs, home-manager }:
let
# x86_64-darwin is deliberately absent: nixpkgs 26.11 dropped it, and
# `legacyPackages.x86_64-darwin` now throws rather than merely failing to
# build — so listing it would break `nix flake show` and `nix flake check`
# for every system, not just that one. Intel macOS needs the 26.05 branch.
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
# Hands each output both the package set and the system name — the latter
# because the shell and the checks reach back into `self.packages` for the
# system being evaluated, and `pkgs.system` is discouraged in favour of a
# considerably wordier spelling.
forAllSystems =
f:
nixpkgs.lib.genAttrs systems (
system:
f {
inherit system;
pkgs = nixpkgs.legacyPackages.${system};
}
);
in
{
packages = forAllSystems (
{ pkgs, ... }: rec {
gitea-axi = pkgs.callPackage ./package.nix { };
gitea-axi-skill = pkgs.runCommandLocal "gitea-axi-skill"
{
passthru.skillName = "gitea-axi";
}
''
mkdir -p "$out"
cp -R ${gitea-axi.skill}/. "$out/"
test -f "$out/SKILL.md"
'';
default = gitea-axi;
}
);
# Declarative ambient context for an operator whose agent configuration is
# generated rather than owned (ADR 0020). Not per-system: it is a module
# function, and the package it defaults to comes from the importing
# configuration's own `pkgs` rather than from this flake's nixpkgs — which
# is how a consumer deduplicates, and the same reason the derivation is a
# callable expression rather than a flake-bound one.
homeModules = rec {
gitea-axi = ./home-manager-module.nix;
default = gitea-axi;
};
# The toolchain the repository actually needs: the build and the fast tier
# want Node, the live end-to-end tier and the benchmark harness additionally
# shell out to `git`, `tea`, and `curl` — none of which the repository
# specifies anywhere else.
#
# Not `gitea-axi` itself, which the benchmark's own arm resolves by name off
# PATH: that has to be the locally built `dist/main.js`, so that a bench run
# measures the working tree rather than whatever the flake last packaged.
# Supplying it here would silently substitute the wrong binary.
devShells = forAllSystems (
{ pkgs, system }: {
default = pkgs.mkShell {
packages = [
# The package's own Node, taken from its passthru rather than named
# a second time here. There is one reference, so development and
# the shipped artifact cannot drift onto different majors — and
# they cannot be set independently even by mistake.
self.packages.${system}.gitea-axi.nodejs
pkgs.git
pkgs.tea
# The benchmark's raw-api arm shells out to curl.
pkgs.curl
];
};
}
);
# Two checks. `gitea-axi` is an alias for the package, so `nix flake check`
# builds it and thereby runs both its verification phases — the fast tier
# in `checkPhase`, the installed-binary tier in `installCheckPhase`.
#
# `home-manager-module` evaluates the home-manager module through real
# home-manager and builds the home files derivation it produces under
# several operator configurations, proving the module's composition (ADR
# 0021). It reuses the package check's store path rather than rebuilding.
#
# No granular per-stage checks for the package: the one stage that would
# add coverage the package build does not already have is the full
# typecheck, which spans `test/` and `bench/` and would therefore drag the
# benchmark harness into the derivation's inputs — undoing the source
# filtering that keeps benchmark churn from forcing a rebuild. That
# typecheck stays in continuous integration, where it already runs.
checks = forAllSystems (
{ pkgs, system }: {
inherit (self.packages.${system}) gitea-axi;
home-manager-module = import ./checks/home-manager-module.nix {
inherit pkgs home-manager;
module = self.homeModules.gitea-axi;
package = self.packages.${system}.gitea-axi;
};
}
);
};
}

75
home-manager-module.nix Normal file
View File

@@ -0,0 +1,75 @@
# A home-manager module installing gitea-axi and, when Claude Code is present,
# its SessionStart hook.
#
# `programs.gitea-axi.enable` installs the CLI, always.
# The Agent Skill is exposed as a package output for agent-agnostic delivery by
# a project's dev shell or an operator's shared skill delivery module.
#
# Importing this module changes nothing until `programs.gitea-axi.enable` is set.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.gitea-axi;
# The package the declarations are read out of. `package = null` opts out of
# putting the binary on PATH, not out of the hook declaration — an operator who
# installs gitea-axi system-wide still wants the hook — so the declarations
# fall back to the default build, which in that arrangement is already in the
# closure anyway.
sourcePackage = if cfg.package != null then cfg.package else defaultPackage;
defaultPackage = pkgs.callPackage ./package.nix { };
in
{
options.programs.gitea-axi = {
enable = lib.mkEnableOption "gitea-axi, an agent-ergonomic CLI for Gitea issues and pull requests";
package = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = defaultPackage;
defaultText = lib.literalExpression "pkgs.callPackage ./package.nix { }";
description = ''
The gitea-axi package to install, or `null` to declare the ambient
Claude Code hook without installing the binary for an operator who
supplies it another way, such as `environment.systemPackages`.
The SessionStart hook records a name resolved on `PATH`, so a binary
installed elsewhere satisfies it.
'';
};
enableClaudeCodeIntegration = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to declare gitea-axi's Claude Code SessionStart hook alongside
the CLI.
The hook lands only when `programs.claude-code.enable` is also on;
with it off it is silently absent, matching how home-manager's own
`enableBashIntegration`-style toggles behave against a disabled sibling.
Turn this off to install gitea-axi declaratively while writing the
Claude Code hook by hand or with `gitea-axi setup`.
'';
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
(lib.mkIf (cfg.package != null) { home.packages = [ cfg.package ]; })
(lib.mkIf cfg.enableClaudeCodeIntegration {
# The hook is declared through the Claude Code module's own settings
# option. That composes it with an operator's own SessionStart hooks
# instead of colliding, and the module drops the declaration for free
# when it is disabled.
programs.claude-code.settings.hooks.SessionStart = [ sourcePackage.sessionStartHook ];
})
]
);
}

10
package-lock.json generated
View File

@@ -17,14 +17,14 @@
"gitea-axi": "dist/main.js"
},
"devDependencies": {
"@types/node": "^20.19.0",
"@types/node": "^22.20.1",
"@vitest/coverage-v8": "^3.2.7",
"tsx": "^4.23.1",
"typescript": "^5.8.0",
"vitest": "^3.2.0"
},
"engines": {
"node": ">=20"
"node": "^22 || ^24"
},
"peerDependencies": {
"@anthropic-ai/claude-agent-sdk": ">=0.3.0"
@@ -1050,9 +1050,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -5,7 +5,7 @@
"type": "module",
"license": "MIT",
"engines": {
"node": ">=20"
"node": "^22 || ^24"
},
"bin": {
"gitea-axi": "dist/main.js"
@@ -35,6 +35,7 @@
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:pack": "vitest run --config vitest.packaging.config.ts",
"test:installed": "vitest run --config vitest.packaging.config.ts --passWithNoTests=false test/packaging/installed-binary.test.ts",
"test:bench": "vitest run --config vitest.bench.config.ts",
"test:bench:smoke": "vitest run --config vitest.bench-smoke.config.ts",
"bench:run": "tsx bench/run.ts",
@@ -46,7 +47,7 @@
"gitea-js": "^1.23.0"
},
"devDependencies": {
"@types/node": "^20.19.0",
"@types/node": "^22.20.1",
"@vitest/coverage-v8": "^3.2.7",
"tsx": "^4.23.1",
"typescript": "^5.8.0",

211
package.nix Normal file
View File

@@ -0,0 +1,211 @@
{
lib,
buildNpmPackage,
importNpmLock,
makeWrapper,
nodejs,
git,
tea,
which,
}:
let
# Where the install check finds the dev dependencies that `npmInstallHook`
# prunes out of the build tree. Named once: it is a contract between
# `preInstall`, which writes it, and `installCheckPhase`, which reads it.
devNodeModules = "$NIX_BUILD_TOP/node_modules-dev";
# The manifest is the canonical version: the release flow bumps it, and
# reading it here means a store path and a released version cannot disagree.
manifest = lib.importJSON ./package.json;
# Where the bundled Agent Skill lands in the output, and the one address a
# consumer may depend on. The Skill's other copy — inside the installed node
# modules tree, where `setup` resolves it relative to its own module — is an
# artefact of how the command finds it at runtime, and moves whenever the
# packaging method changes.
skillSubdir = "share/gitea-axi/skills/gitea-axi";
# The SessionStart hook entry, read from the committed specification rather
# than written out here. The imperative `setup hooks` writes this same entry
# through the agent SDK, and a test drives it and asserts the two agree — so
# declaring it a second time in Nix would be a second source of truth with
# nothing checking it against the first.
sessionStartHook = lib.importJSON ./session-start-hook.json;
# An explicit allowlist of what the build and its tests actually read. The
# repository's highest-churn directories — .claude, bench, prose docs — are
# all build-irrelevant, so a whole-repository source would let writing an ADR
# invalidate the derivation and force a rebuild with a full test run.
#
# Adding a build-relevant top-level file means adding it here too; the build
# otherwise fails on a missing file.
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./src
# The fast tier only. `test/e2e` needs a live Gitea host and is excluded
# from the runner config, so admitting it would let end-to-end churn
# invalidate the derivation — the very cost this allowlist exists to
# avoid. `test/packaging` stays: task 0038 drives it against the
# installed binary.
(lib.fileset.difference ./test ./test/e2e)
./skills
./package.json
./package-lock.json
./tsconfig.json
./tsconfig.build.json
./vitest.config.ts
./vitest.packaging.config.ts
# Read by the fast tier, which asserts the imperative hook install writes
# what this declares. Also read at evaluation time above, but that read is
# of the flake source rather than of `src` and would not require it here.
./session-start-hook.json
];
};
in
buildNpmPackage (finalAttrs: {
pname = "gitea-axi";
inherit (manifest) version;
inherit src nodejs;
# Each dependency's fetch is derived from the integrity fields already in the
# lockfile, so a lockfile change needs no edit here. A single fixed-output
# hash would break on every dependency bump and be repaired by copying a hash
# out of an error message — a permanent recurring tax.
npmDeps = importNpmLock { npmRoot = src; };
inherit (importNpmLock) npmConfigHook;
nativeBuildInputs = [ makeWrapper ];
# The fast tier only. The live end-to-end and benchmark smoke tiers need a
# live Gitea host. Two of these test files invoke `git` directly and one
# resolves it with `which`; `tea` is already stubbed within this tier.
doCheck = true;
nativeCheckInputs = [
git
which
];
# `buildNpmPackage` wires config, build and install hooks but no check hook, so
# `doCheck` alone is inert and the phase has to be spelled out. `git init` and
# `git commit` in the fast tier also need a writable HOME, which the sandbox
# otherwise points at a non-existent directory.
checkPhase = ''
runHook preCheck
export HOME=$(mktemp -d)
npm run test
# vitest leaves a run cache under node_modules/.vite whose results.json
# records durations and timestamps. `npmInstallHook` copies node_modules
# into $out wholesale, so leaving it there both ships a stray cache in the
# closure and makes the output non-reproducible `nix build --rebuild`
# reports the derivation "may not be deterministic" on that one file.
rm -rf node_modules/.vite
runHook postCheck
'';
# `npmInstallHook` prunes dev dependencies out of the build tree's
# node_modules on its way to assembling $out, which would take vitest with it
# and leave the install check with nothing to run. Snapshot the tree first —
# as hardlinks, so it costs neither time nor space, and so the prune's
# deletions do not follow through to the copy.
preInstall = ''
cp -al node_modules "${devNodeModules}"
'';
# ADR 0018: append, never prepend. The operator's own `tea` owns the
# credential store it refreshes in place, so the closure's copy is a
# fresh-machine fallback rather than an override.
#
# The Agent Skill is also published under a stable address (ADR 0020), so a
# Nix expression can install it declaratively without reaching into the node
# modules tree. `cp` failing on a missing source is the guard that this
# address keeps pointing at something.
postInstall = ''
wrapProgram $out/bin/gitea-axi \
--suffix PATH : ${lib.makeBinPath [ git tea ]}
mkdir -p "$(dirname "$out/${skillSubdir}")"
cp -R skills/gitea-axi "$out/${skillSubdir}"
'';
# Drive the binary that was just installed through the shared installed-binary
# tier, which the npm distribution path drives too — so the two cannot drift
# apart in what they guarantee about an installed gitea-axi.
#
# This guards a class of failure `checkPhase` structurally cannot reach,
# because it runs against the source tree rather than an installation. The
# one that bites here is Skill resolution: `setup` locates the bundled Agent
# Skill relative to its own module location, so the built output's position
# relative to that Skill is load-bearing — an arrangement that exists only
# once installed. A probe moving the installed `skills` aside does fail this
# phase.
#
# The tier's executable-bit assertion carries less weight under Nix than
# under npm, and deliberately so: `nodejsInstallExecutables` generates a
# wrapper invoking `node <path>` rather than symlinking the entrypoint, so
# the bit that matters is the one on `$out/bin/gitea-axi`, which makeWrapper
# always sets. That assertion earns its keep on the npm path, where npm sets
# the bit from the manifest's `bin` entry and `tsc` does not. Sharing one
# tier means neither path picks which guarantees it feels like offering.
#
# `installCheckPhase` runs after `fixupPhase`, so the binary named here is the
# wrapped one an operator would actually get. Naming it is all this phase
# does: the assertions live in the tier, not in shell script here.
doInstallCheck = true;
installCheckPhase = ''
runHook preInstallCheck
# Restore by copying, not moving, so the snapshot survives for a replayed
# phase `--keep-failed` debugging, or `genericBuild` re-entered by hand.
rm -rf node_modules
cp -al "${devNodeModules}" node_modules
export HOME=$(mktemp -d)
GITEA_AXI_INSTALLED_BIN=$out/bin/gitea-axi npm run test:installed
runHook postInstallCheck
'';
# The package's declared interface to Nix expressions, published rather than
# left to be read off the build environment or guessed at from the output's
# layout. Every attribute here has a consumer that breaks if it is removed.
passthru = {
# The Node the package is built against. The flake's dev shell consumes
# exactly this, so development and the shipped artifact cannot drift onto
# different majors.
inherit nodejs;
# The bundled Agent Skill's directory, for a configuration that packages it
# into an agent-agnostic project-local delivery mechanism. A directory rather
# than the SKILL.md inside it, so a Skill that grows helper files stays one
# reference.
skill = "${finalAttrs.finalPackage}/${skillSubdir}";
# The SessionStart hook entry, verbatim as it belongs in a Claude Code
# settings.json. Evaluating this builds nothing: it is the committed
# specification, and the command it records is a name resolved on PATH
# rather than a store path (ADR 0019).
inherit sessionStartHook;
};
meta = {
inherit (manifest) description homepage;
# Looked up by SPDX identifier rather than hardcoded, for the same reason
# the version is read from the manifest: one canonical source, no second
# place to update on a relicence.
license = lib.licensesSpdx.${manifest.license};
mainProgram = "gitea-axi";
# Broader than the flake's `systems` list, deliberately. This describes what
# the package supports — everything, since it contains no compiled code —
# whereas that list encodes which systems the pinned nixpkgs can still
# evaluate. Consumed against 26.05, x86_64-darwin builds fine from here.
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
})

10
session-start-hook.json Normal file
View File

@@ -0,0 +1,10 @@
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "gitea-axi",
"timeout": 10
}
]
}

View File

@@ -6,30 +6,34 @@ description: Use when working with a Gitea repository's issues, pull requests, l
# gitea-axi
`gitea-axi` is an agent-ergonomic CLI for a Gitea repository's issues and pull requests.
Its output is compact TOON built for another program to read, and its errors are structured with actionable suggestions.
Its output is compact TOON meant to be read directly, and a failed command's error names the fix — follow that suggestion rather than guessing at another command.
## When to use it
## Targeting and authentication
Reach for `gitea-axi` whenever a task touches a Gitea repository's issues, pull requests, labels, or reviews.
Every command resolves a repository and credentials; getting both right on the first call is the difference between one command and a retry.
- **Over `tea`:** `gitea-axi` returns structured output and typed errors instead of human-formatted tables, and it defaults the repository and login from the local checkout.
- **Over raw Gitea API calls:** it handles auth, pagination, name-to-ID resolution, and review-decision aggregation for you, so you do not hand-roll HTTP.
- **Over improvised `git`:** for anything about issues or pull requests as entities (state, reviews, labels, comments) rather than local commits and branches.
- **Repository.** Inside a Gitea checkout it comes from the `origin` remote.
Outside one, pass `-R OWNER/NAME` on every command, or set `GITEA_AXI_REPO=OWNER/NAME` once for the session.
- **Credentials.** With `GITEA_AXI_TOKEN` and `GITEA_AXI_API_URL` set, authentication is automatic.
Otherwise pass `--login <name>`, or set `GITEA_AXI_LOGIN`.
Run it inside a Gitea checkout, or pass `-R OWNER/NAME` (and `--login <name>`) to target a repository explicitly.
Outside a checkout with the token in the environment, `gitea-axi <command> -R OWNER/NAME` is the whole invocation — don't look for a config file or a login profile.
## Command groups
## Commands
- `issue` — list, view, create, comment on, edit, close/reopen, pin, and link issues.
- `pr`create, view, comment on, edit, review, merge, check out, diff, and inspect the checks of pull requests.
- `label` — list, create, edit, and delete labels.
- `search` — full-text search across issues and pull requests.
- `setup` — install this skill (`setup`) and, opt-in, the SessionStart dashboard hook (`setup hooks`).
- `issue` — list, view, create, comment, edit, close, reopen, pin, and link (blocks / blocked-by).
- `pr`list, view, create, comment, edit, review, merge, close, reopen, diff, checks, and checkout.
- `label` — list, create, edit, delete.
- `search issues "<query>"` and `search prs "<query>"` — full-text search (a bare `search "<query>"` is not valid).
## Discovery
## Finding and acting
This skill is a pointer, not a command reference — the CLI is the single source of truth for its own interface.
Find the target, then act on it — two commands, not a survey of the repository.
- Run `gitea-axi` with no arguments for the repository dashboard (open issues and pull requests).
Add `--full` for the open-PR table and issue counts by label.
- Run `gitea-axi <command> --help` (or `gitea-axi <group> <command> --help`) for the exact flags of any command.
- **Find it.** If you already know the number, act on it directly.
Otherwise reach for one command — `search issues "<query>"` for a title or keyword, or `issue list --state all --label <name>` to narrow by a property — not both.
- **Read one issue or PR.** `issue view <number>` (or `pr view <number>`) shows labels and state by default, and takes `--fields assignees,milestone,…` for the rest.
You do not need `issue list` to answer a question about a single known issue.
- **Act on it.** `issue edit <number>` and `pr edit <number>` change fields with repeatable `--add-label` / `--remove-label` and `--add-assignee` / `--remove-assignee`, plus `--title`, `--body`, and `--milestone`.
Reviewing is `pr review <number>` with exactly one of `--approve`, `--request-changes`, or `--comment`, and an optional `--body`.
A comment is `issue comment <number> --body <text>`; a new label is `label create --name <text> --color <hex-without-#>`.

View File

@@ -1,7 +1,6 @@
import { readFileSync } from "node:fs";
import { isAbsolute, resolve } from "node:path";
import type { CliDeps } from "./deps.js";
import { axiError } from "./errors.js";
import { readFlagFile } from "./flag-file.js";
import { flagValue } from "./flags.js";
/**
@@ -60,15 +59,5 @@ function bodyFlagSuggestion(command: string): string[] {
}
function readBodyFile(deps: CliDeps, path: string, command: string): string {
const absolute = isAbsolute(path) ? path : resolve(deps.cwd, path);
try {
return readFileSync(absolute, "utf8");
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw axiError(
`Cannot read --body-file ${path}: ${reason}`,
"VALIDATION_ERROR",
bodyFlagSuggestion(command),
);
}
return readFlagFile(deps, path, "--body-file", bodyFlagSuggestion(command));
}

View File

@@ -222,8 +222,11 @@ Show a single issue. Pull request numbers are rejected — use \`pr view\` inste
flags:
--comments Render every comment in full (bodies truncated at 800 chars)
--full Suppress all truncation of the issue body and comment bodies
--fields <a,b,c> Append extra fields: assignees, closedAt, milestone, updatedAt, url
--help Show this help
Labels are shown by default; use --fields to add assignees, milestone, and more.
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
@@ -460,30 +463,51 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
return renderList({
noun: "issues",
rows,
countLine: formatCountLine(rows.length, total, rows.length >= limit),
countLine: formatCountLine(rows.length, total, rows.length >= limit, countStateQualifier(state)),
help: issueListSuggestions(context, state, rows.length, total),
});
}
// The count line names the state the list was filtered to, so the answer to
// "how many are open?" is on the summary line rather than only inferable from
// each row. `all` imposes no narrowing and has no natural one-word name, so it
// adds no qualifier and the generic count line stands.
function countStateQualifier(state: IssueState): string | undefined {
return state === "all" ? undefined : state;
}
// The default detail fields reuse the same declarative extraction as the list
// path; only `body` (truncation) and `comment_count` need bespoke handling.
const ISSUE_VIEW_FIELDS: FieldDef<Issue>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
joined("labels", "labels", "name"),
pluck("author", "user.login"),
relativeTimeField("created", "created_at"),
];
// Appended to the default view fields on request via `--fields`, never replacing
// them. Labels and body are shown by default, so they are not offered here.
const ISSUE_VIEW_EXTRA_FIELDS: Record<string, FieldDef<Issue>> = {
assignees: joined("assignees", "assignees", "login"),
closedAt: relativeTimeField("closedAt", "closed_at"),
milestone: pluck("milestone", "milestone.title"),
updatedAt: relativeTimeField("updatedAt", "updated_at"),
url: pluck("url", "html_url"),
};
interface IssueDetailOptions {
host: string;
full: boolean;
withComments: boolean;
now: Date;
/** Extra fields selected via `--fields`, appended after the defaults. */
extraFields: FieldDef<Issue>[];
}
function buildIssueDetail(issue: Issue, options: IssueDetailOptions): Record<string, unknown> {
const row = extractRow(issue, ISSUE_VIEW_FIELDS, {
const row = extractRow(issue, [...ISSUE_VIEW_FIELDS, ...options.extraFields], {
now: options.now,
host: options.host,
full: options.full,
@@ -531,12 +555,21 @@ async function issueView(deps: CliDeps, args: string[]): Promise<string> {
}
const { flags, positionals } = parseFlags(
args,
{ "--comments": { takesValue: false }, "--full": { takesValue: false } },
{
"--comments": { takesValue: false },
"--full": { takesValue: false },
"--fields": { takesValue: true },
},
"issue view",
);
const number = parsePositionalNumber(positionals, "issue view", "issue");
const full = flags["--full"] === true;
const withComments = flags["--comments"] === true;
const extraFields = selectExtraFields(
flagValue(flags, "--fields"),
ISSUE_VIEW_EXTRA_FIELDS,
"issue view",
);
const context = await resolveRepoContext(deps);
const api = createClient(context);
@@ -549,7 +582,7 @@ async function issueView(deps: CliDeps, args: string[]): Promise<string> {
}
const now = new Date();
const item = buildIssueDetail(issue, { host: context.host, full, withComments, now });
const item = buildIssueDetail(issue, { host: context.host, full, withComments, now, extraFields });
const blocks: DetailBlock[] = [];
if (withComments) {

View File

@@ -38,12 +38,13 @@ import {
splitFlag,
} from "../flags.js";
import { fetchChecks } from "../checks.js";
import { fetchPullDiff, truncateDiff } from "../diff.js";
import { fetchPullDiff, trimDiffHunk, truncateDiff } from "../diff.js";
import { checkoutPullHead, currentBranch } from "../git.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { fetchAllPages, readTotalCount } from "../paginate.js";
import { formatCountLine, renderDetail, renderList, renderScalar, type DetailBlock } from "../render.js";
import { fetchReviewComments, fetchReviewDecision, fetchReviews } from "../review.js";
import { loadInlineComments, resolveInlineComments } from "../review-comments.js";
import { suggestCommand } from "../suggestions.js";
import { relativeTime } from "../time.js";
@@ -284,6 +285,8 @@ flags:
--comment Leave a review comment without approving or rejecting
--body <text> Review body
--body-file <path> Read the review body from a file (mutually exclusive with --body)
--comments-file <path> JSON array of inline comments to submit with the review;
each entry is {reply_to, body} or {path, line, body}
--help Show this help
global flags:
@@ -739,6 +742,19 @@ function headSha(pull: PullRequest): string {
return sha;
}
/**
* The id Gitea gave a review comment — the handle a reply targets. The client
* types it optional, but every real comment has one, and an id invented to fill
* the gap would be reported as fact and copied into a `reply_to`, so a comment
* without one is treated as the broken answer it is (mirroring {@link pullNumber}).
*/
function reviewCommentId(comment: PullReviewComment): number {
if (comment.id === undefined) {
throw axiError("Gitea returned a review comment with no id", "UNKNOWN");
}
return comment.id;
}
interface PrDetailOptions {
host: string;
full: boolean;
@@ -813,6 +829,13 @@ interface ReviewRowsOptions {
* `official`/`stale` flags and its inline (diff) comments. One comments fetch per
* review, all in flight at once; review and comment bodies truncate at 800 chars
* unless `--full` is set.
*
* Each inline comment carries its anchor: `id` (the reply handle), `resolved`
* (`yes`/`no` from whether Gitea populated the comment's `resolver`), and
* `diff_hunk` — structurally trimmed to its `@@` header plus tail by default, or
* emitted verbatim under `--full`. The raw `position`/`original_position` diff
* offsets are deliberately not surfaced: they are unmappable to a file line
* without the patch, and the `@@` header already carries the line range.
*/
async function buildReviewRows(
api: GiteaClient,
@@ -837,8 +860,11 @@ async function buildReviewRows(
stale: review.stale ? "yes" : "no",
body: truncate(review.body ?? ""),
comments: commentLists[index]!.map((comment) => ({
id: reviewCommentId(comment),
author: comment.user?.login ?? "",
path: comment.path ?? "",
resolved: comment.resolver ? "yes" : "no",
diff_hunk: options.full ? (comment.diff_hunk ?? "") : trimDiffHunk(comment.diff_hunk ?? ""),
body: truncate(comment.body ?? ""),
})),
}));
@@ -1200,15 +1226,18 @@ async function prReview(deps: CliDeps, args: string[]): Promise<string> {
"--comment": { takesValue: false },
"--body": { takesValue: true },
"--body-file": { takesValue: true },
"--comments-file": { takesValue: true },
},
"pr review",
);
const number = parsePositionalNumber(positionals, "pr review", "pull request");
// Everything the caller's own input can settle is checked before any request
// goes out: the action flag count first, then the body source.
// goes out: the action flag count first, then the body source, then the
// inline-comment batch (parsed and shape-validated from the file).
const chosen = resolveReviewAction(flags);
const body = resolveBodySource(deps, flags, "pr review");
const inlineComments = loadInlineComments(deps, flags, "pr review");
const context = await resolveRepoContext(deps);
const api = createClient(context);
@@ -1217,15 +1246,24 @@ async function prReview(deps: CliDeps, args: string[]): Promise<string> {
if (body !== undefined) {
payload.body = body;
}
// Replies are resolved against the PR's existing comments before the POST, so
// an unknown `reply_to` fails without a submission ever going out.
if (inlineComments !== undefined && inlineComments.length > 0) {
payload.comments = await resolveInlineComments(api, context, number, inlineComments);
}
try {
await api.repos.repoCreatePullReview(context.owner, context.name, number, payload);
} catch (error) {
throw classifyHttpError(error);
}
const item: Record<string, unknown> = { number, action: chosen.action };
if (payload.comments !== undefined) {
item.comments = payload.comments.length;
}
return renderDetail({
noun: "review",
item: { number, action: chosen.action },
item,
help: [suggestCommand(context, `pr view ${number} --reviews`, "to see the review in full")],
});
}

View File

@@ -107,6 +107,10 @@ interface SearchKind {
noun: string;
/** The command a matched number feeds into. */
viewCommand: string;
/** The list command suggested as the fallback when a search finds nothing. */
listCommand: string;
/** Human plural for the fallback note, e.g. "issues" or "pull requests". */
things: string;
/** The `--help` text for this variant. */
help: string;
}
@@ -116,6 +120,8 @@ const SEARCH_ISSUES: SearchKind = {
type: "issues",
noun: "issues",
viewCommand: "issue view",
listCommand: "issue list",
things: "issues",
help: SEARCH_ISSUES_HELP,
};
@@ -124,6 +130,8 @@ const SEARCH_PRS: SearchKind = {
type: "pulls",
noun: "pull_requests",
viewCommand: "pr view",
listCommand: "pr list",
things: "pull requests",
help: SEARCH_PRS_HELP,
};
@@ -217,11 +225,24 @@ async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promi
extractRow(issue, [...SEARCH_FIELDS, ...extraFields], { now, host: context.host, full }),
);
// The next-step suggestion is conditioned on the match count. On a miss the
// `view` hint is nonsensical, so point at the non-indexed list as a fallback
// (it recovers from both an over-narrow query and index lag); on a single match
// fill the real number (Principle 9's single-id fill); otherwise leave the
// number parameterized. Search stays a locator either way — it never auto-loads
// the detail (see ADR 0017).
const suggestion =
total === 0
? suggestCommand(context, `${kind.listCommand} --state all`, `to list all ${kind.things} instead`)
: total === 1
? suggestCommand(context, `${kind.viewCommand} ${matches[0]!.number}`, "to see it in full")
: suggestCommand(context, `${kind.viewCommand} <number>`, "to see a match in full");
return renderList({
noun: kind.noun,
rows,
countLine: formatCountLine(rows.length, total, false),
help: [suggestCommand(context, `${kind.viewCommand} <number>`, "to see a match in full")],
help: [suggestion],
});
}

View File

@@ -4,7 +4,13 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { installSessionStartHooks } from "axi-sdk-js";
import type { CliDeps } from "../deps.js";
import { axiError } from "../errors.js";
import {
axiError,
isNotWritableError,
isNotWritableMessage,
unwritableTargetError,
} from "../errors.js";
import { pruneDuplicateManagedHooks, resolveEntrypointOnPath } from "../hooks.js";
import { renderDetail } from "../render.js";
export const SETUP_HELP = `usage: gitea-axi setup [hooks]
@@ -30,6 +36,11 @@ const SKILL_NAME = "gitea-axi";
const SKILL_SOURCE = new URL("../../skills/gitea-axi/SKILL.md", import.meta.url);
const EXEC_PATH = fileURLToPath(new URL("../main.js", import.meta.url));
// The same string as SKILL_NAME, kept apart because it names a different thing:
// the executable as it is spelled on PATH, which is what the SessionStart hook
// records. They are free to diverge; the SDK's marker follows the skill.
const BINARY_NAME = "gitea-axi";
const HOOK_INTEGRATIONS = ["Claude Code", "Codex", "OpenCode"];
/** The home directory, from the injected env first so tests can point at a temp HOME. */
@@ -59,15 +70,30 @@ function installSkill(home: string): { skill: string; path: string; status: Skil
const targetPath = join(targetDir, "SKILL.md");
let status: SkillStatus;
if (!existsSync(targetPath)) {
status = "installed";
} else {
status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated";
}
try {
if (!existsSync(targetPath)) {
status = "installed";
} else {
status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated";
}
if (status !== "unchanged") {
mkdirSync(targetDir, { recursive: true });
writeFileSync(targetPath, source, "utf8");
// A target that already holds these bytes needs no write, so its being
// read-only is beside the point and the run succeeds.
if (status !== "unchanged") {
mkdirSync(targetDir, { recursive: true });
writeFileSync(targetPath, source, "utf8");
}
} catch (error) {
// The comparison read is inside the guard because a target the filesystem
// will not let us read is one it will not let us replace either — the same
// condition, reached one call earlier.
if (isNotWritableError(error)) {
// The path the filesystem names is the directory when it is the directory
// that is read-only, so report that rather than assuming the file.
const blocked = (error as { path?: string }).path ?? targetPath;
throw unwritableTargetError(blocked, `the ${SKILL_NAME} skill`);
}
throw error;
}
return { skill: SKILL_NAME, path: collapseHome(targetPath, home), status };
@@ -85,23 +111,129 @@ async function setupSkill(deps: CliDeps): Promise<string> {
});
}
// The two files the SDK writes the SessionStart hook array into. Its third
// integration, OpenCode, is a whole plugin file it rewrites wholesale behind
// its own managed marker, so that one cannot accumulate duplicates.
const HOOK_SETTINGS_FILES = [
[".claude", "settings.json"],
[".codex", "hooks.json"],
];
/** One target the hook install could not write, and why. */
interface TargetFailure {
/** The file being written when it failed. */
path: string;
/** The underlying failure, with no path spliced into it. */
detail: string;
}
/**
* Recover a {@link TargetFailure} from the agent SDK's reporting.
*
* It hands its failures to `onError` as `<path>: <message>` text rather than as
* errors, so the two halves have to be separated again before either can be
* judged on its own. Text in that shape is all it ever emits; anything else is
* returned whole as the detail, with no path to attribute it to.
*/
function parseReportedFailure(reported: string): TargetFailure {
const separator = reported.indexOf(": ");
if (separator === -1) {
return { path: "", detail: reported };
}
return {
path: reported.slice(0, separator),
detail: reported.slice(separator + 2),
};
}
/** A failure rendered back as the `<path>: <detail>` line a reader sees. */
function formatFailure({ path, detail }: TargetFailure): string {
return path === "" ? detail : `${path}: ${detail}`;
}
/**
* Collapse any duplicate managed entry the SDK's own recognition missed.
*
* It identifies its hook by finding the marker inside the recorded command, so
* an entrypoint path that does not happen to contain "gitea-axi" makes a re-run
* append a second entry rather than update the first. Matching the exact
* command this run records makes idempotency independent of the recorded
* command's shape — and cannot mistake another tool's hook for ours the way a
* substring test can.
*/
function pruneHookSettingsFiles(
home: string,
command: string,
errors: TargetFailure[],
): void {
const isManaged = (recorded: string) => recorded === command;
for (const segments of HOOK_SETTINGS_FILES) {
const target = join(home, ...segments);
if (!existsSync(target)) {
continue;
}
try {
const current = JSON.parse(readFileSync(target, "utf8"));
const { settings, changed } = pruneDuplicateManagedHooks(current, isManaged);
if (changed) {
writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
}
} catch (error) {
errors.push({
path: target,
detail: error instanceof Error ? error.message : String(error),
});
}
}
}
async function setupHooks(deps: CliDeps): Promise<string> {
const home = resolveHome(deps);
const errors: string[] = [];
const errors: TargetFailure[] = [];
// ADR 0019: record a search-path name, not an install-tree path. Handing the
// SDK where the binary resolves on PATH — rather than the module-relative
// entrypoint — is what lets its realpath test succeed for a wrapper-based
// install, so it records the bare, upgrade-stable name. When the name
// resolves to no wrapper or symlink of ours, the entrypoint stands as the
// fallback and the absolute path is recorded exactly as before.
//
// PATH is read from the process rather than the injected environment on
// purpose: it has to be the same PATH the SDK itself probes, and the SDK
// reads its own.
const onPath = resolveEntrypointOnPath(BINARY_NAME, EXEC_PATH, process.env.PATH);
const execPath = onPath ?? EXEC_PATH;
const command = onPath ? BINARY_NAME : EXEC_PATH;
installSessionStartHooks({
marker: SKILL_NAME,
binaryNames: [SKILL_NAME],
execPath: EXEC_PATH,
execPath,
homeDir: home,
// This is an explicit user command, so install unconditionally rather than
// deferring to the SDK's auto-install safety gate (which is tuned for the
// inferred dist/bin/<name>.js entrypoint layout gitea-axi does not use).
shouldInstall: () => true,
onError: (message) => errors.push(message),
onError: (message) => errors.push(parseReportedFailure(message)),
});
pruneHookSettingsFiles(home, command, errors);
if (errors.length > 0) {
throw axiError(`Failed to install session hooks: ${errors.join("; ")}`, "UNKNOWN");
// A read-only target is the most actionable thing that can be in here — it
// names something the user must settle elsewhere rather than a bug — so it
// is reported ahead of whatever else was collected.
const unwritable = errors.find(
(failure) => failure.path !== "" && isNotWritableMessage(failure.detail),
);
if (unwritable) {
throw unwritableTargetError(unwritable.path, `the ${SKILL_NAME} session hook`);
}
throw axiError(
`Failed to install session hooks: ${errors.map(formatFailure).join("; ")}`,
"UNKNOWN",
);
}
return renderDetail({

View File

@@ -60,6 +60,17 @@ function hostnameOf(url: string, origin: string): string {
}
}
/**
* Normalize a Gitea base URL to the host root the client expects. The client
* (gitea-js) appends `/api/v1` itself, so a value that already carries it — a
* natural guess when the variable is literally named `..._API_URL` — would double
* the segment and 404 as a spurious `REPO_NOT_FOUND`. Strip a trailing `/api/v1`
* (with any trailing slashes) so the host base and the API endpoint both work.
*/
function normalizeApiBase(url: string): string {
return url.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
}
function resolveTestModeContext(
deps: CliDeps,
apiUrl: string,
@@ -72,10 +83,11 @@ function resolveTestModeContext(
["Set `GITEA_AXI_REPO=OWNER/NAME` or pass `-R OWNER/NAME`"],
);
}
const base = normalizeApiBase(apiUrl);
return {
...parseRepoSpec(overrides.repoSpec, overrides.repoOrigin),
host: hostnameOf(apiUrl, "`GITEA_AXI_API_URL`"),
apiUrl: apiUrl.replace(/\/+$/, ""),
host: hostnameOf(base, "`GITEA_AXI_API_URL`"),
apiUrl: base,
token: deps.env.GITEA_AXI_TOKEN ?? "",
repoSource: overrides.repoSource,
loginSource: overrides.loginSource,
@@ -184,7 +196,7 @@ export async function resolveRepoContext(deps: CliDeps): Promise<RepoContext> {
owner,
name,
host,
apiUrl: login.url.replace(/\/+$/, ""),
apiUrl: normalizeApiBase(login.url),
token,
repoSource: overrides.repoSource,
loginSource: overrides.loginSource,

View File

@@ -1,6 +1,6 @@
import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js";
import { classifyHttpError } from "./errors.js";
import { axiError, classifyHttpError } from "./errors.js";
/** The raw-diff truncation limit, distinct from the body/comment limits. */
export const DIFF_TRUNCATE_LIMIT = 4000;
@@ -56,3 +56,73 @@ export function truncateDiff(diff: string, full: boolean): DiffResult {
original_length: diff.length,
};
}
/**
* Structurally trim a review comment's `diff_hunk` to its anchor: the `@@`
* header line plus the hunk's last two lines. A hunk of three lines or fewer is
* already covered by that (header + last two), so it is returned unchanged.
*
* This is deliberately not the char-based body truncation: it keeps both the
* file-line anchor (the `@@` header) and the code at the comment (the tail),
* which a keep-head char truncation would get backwards by dropping the tail.
*/
export function trimDiffHunk(hunk: string): string {
const lines = hunk.split("\n");
if (lines.length <= 3) {
return hunk;
}
return [lines[0], ...lines.slice(-2)].join("\n");
}
/**
* The file-line anchor of a review comment, as the side-tagged position the
* review-submission payload wants: exactly one of `new_position` (new side) or
* `old_position` (old side) is set.
*/
export interface HunkAnchor {
new_position?: number;
old_position?: number;
}
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
/**
* Reconstruct the file-line anchor of the line a review comment's `diff_hunk`
* belongs to. Gitea builds a comment's `diff_hunk` so it ends at the commented
* line, so the anchor is that last body line: starting from the `@@ -old +new @@`
* header, walk the hunk tracking old- and new-file line numbers, and read the
* last line off. An added (`+`) or context (` `) line anchors on the new side
* (`new_position`); a deleted (`-`) line anchors on the old side (`old_position`).
*
* This lets a reply post a matching inline comment that threads with its target
* without the caller supplying any line or side — Gitea joins comments on the
* same line into one conversation.
*/
export function anchorFromDiffHunk(hunk: string): HunkAnchor {
const lines = hunk.split("\n");
const header = HUNK_HEADER.exec(lines[0] ?? "");
if (header === null) {
throw axiError(
"Gitea returned a review comment whose diff_hunk has no @@ header to anchor a reply",
"UNKNOWN",
);
}
let oldLine = Number(header[1]);
let newLine = Number(header[2]);
// An empty body is degenerate; default to the header's new-side start.
let anchor: HunkAnchor = { new_position: newLine };
for (const line of lines.slice(1)) {
if (line.startsWith("-")) {
anchor = { old_position: oldLine };
oldLine += 1;
} else if (line.startsWith("+")) {
anchor = { new_position: newLine };
newLine += 1;
} else {
anchor = { new_position: newLine };
oldLine += 1;
newLine += 1;
}
}
return anchor;
}

View File

@@ -10,6 +10,7 @@ export type AxiErrorCode =
| "TEA_NOT_INSTALLED"
| "VALIDATION_ERROR"
| "GIT_ERROR"
| "TARGET_NOT_WRITABLE"
| "UNKNOWN";
export function axiError(
@@ -20,6 +21,50 @@ export function axiError(
return new AxiError(message, code, suggestions);
}
// The three ways a filesystem refuses a write for reasons the user must settle
// outside this tool: the permission bits deny it, the file is flagged immutable
// or otherwise protected, or the filesystem itself is mounted read-only.
const NOT_WRITABLE_ERRNOS = ["EACCES", "EPERM", "EROFS"];
/** Whether a caught filesystem error means the target cannot be written. */
export function isNotWritableError(error: unknown): boolean {
const errno = (error as { code?: unknown } | null)?.code;
return typeof errno === "string" && NOT_WRITABLE_ERRNOS.includes(errno);
}
/**
* The same judgement made from an error's *message*, for a caller handed the
* formatted text rather than the error object.
*
* This takes the message alone, never a string the target's path has been
* spliced into: a path is the user's to name, and one that happened to contain
* `EACCES` would otherwise misreport an unrelated failure as a read-only target.
*/
export function isNotWritableMessage(message: string): boolean {
return NOT_WRITABLE_ERRNOS.some((errno) => message.includes(errno));
}
/**
* A read-only target reported as something the user can act on.
*
* The remedy is deliberately general. Read-only is not diagnostic of any
* particular configuration manager, and every plausible cause — a declarative
* home manager, an immutable flag, a root-owned path — has the same answer:
* whatever renders the file read-only is where this belongs, not here.
*
* `subject` names what the caller was installing, for the remedy line.
*/
export function unwritableTargetError(path: string, subject: string): AxiError {
return axiError(
`Cannot write ${path}: it is not writable, so it appears to be managed by another tool`,
"TARGET_NOT_WRITABLE",
[
`Declare ${subject} through that tool's configuration rather than installing it with this command`,
`Or make ${path} writable and re-run`,
],
);
}
interface HttpResponseLike {
status: number;
url: string;

25
src/flag-file.ts Normal file
View File

@@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { isAbsolute, resolve } from "node:path";
import type { CliDeps } from "./deps.js";
import { axiError } from "./errors.js";
/**
* Read the file a path-valued flag points at, resolved against the caller's cwd.
* A missing or unreadable file is a `VALIDATION_ERROR` naming the flag — the
* shared reader behind `--body-file` and `--comments-file`. Parsing the contents
* (as text, JSON, …) is the caller's job; this only turns a path into bytes.
*/
export function readFlagFile(
deps: CliDeps,
path: string,
flag: string,
suggestion: string[],
): string {
const absolute = isAbsolute(path) ? path : resolve(deps.cwd, path);
try {
return readFileSync(absolute, "utf8");
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw axiError(`Cannot read ${flag} ${path}: ${reason}`, "VALIDATION_ERROR", suggestion);
}
}

217
src/hooks.ts Normal file
View File

@@ -0,0 +1,217 @@
import { readFileSync, realpathSync, statSync } from "node:fs";
import { delimiter, join } from "node:path";
/** Filesystem reads {@link resolveEntrypointOnPath} needs, injectable for tests. */
export interface PathProbe {
/** The resolved real path of `candidate`, or `undefined` if it is not a file. */
realPath: (candidate: string) => string | undefined;
/** The contents of `candidate`, or `undefined` if it cannot be read as text. */
readText: (candidate: string) => string | undefined;
}
/**
* Where `name` resolves on `pathValue` *to the program running `entrypoint`*, or
* `undefined` when it resolves nowhere, or resolves to some other program.
*
* This exists so `setup hooks` can hand the agent SDK the location the binary
* actually resolves to on `PATH` rather than the module-relative entrypoint.
* The SDK records a bare, upgrade-stable name only when a `PATH` entry
* realpath-matches the path it is given, and from the entrypoint's side that
* can only ever succeed under npm, which symlinks its `bin` entry straight at
* it. A wrapper-based install cannot: a script that *invokes* a file never
* resolves *to* that file.
*
* The two install shapes are therefore recognised on their own terms:
*
* - a **symlink** to the entrypoint, matched by realpath — npm's shape;
* - a **generated wrapper** naming the entrypoint in its text, matched by
* containment — the shape Nix, shims and `.cmd` launchers all produce.
*
* Wrappers chain, so containment follows the references a wrapper makes to
* other files rather than only reading the first one. A Nix install is two
* hops: `bin/gitea-axi` sets `PATH` and execs `bin/.gitea-axi-wrapped`, which
* is what actually names the entrypoint.
*
* Requiring one of these is what keeps the answer honest. Accepting any file
* that merely *bears the name* would hand the SDK a path that trivially
* realpath-matches itself, turning its check into a tautology and recording a
* bare name that resolves to a different program than the one asked.
*
* Windows `PATHEXT` suffixes are deliberately not tried. The package supports
* Linux and macOS, and wherever the lookup misses — an unreadable wrapper, a
* compiled launcher, a chain deeper than {@link MAX_WRAPPER_HOPS} — the
* caller's absolute-path fallback still produces a working hook.
*/
export function resolveEntrypointOnPath(
name: string,
entrypoint: string,
pathValue: string | undefined,
probe: PathProbe = defaultPathProbe,
): string | undefined {
const entrypointReal = probe.realPath(entrypoint);
for (const dir of (pathValue ?? "").split(delimiter)) {
if (!dir) {
continue;
}
const candidate = join(dir, name);
const candidateReal = probe.realPath(candidate);
if (!candidateReal) {
continue;
}
if (entrypointReal !== undefined && candidateReal === entrypointReal) {
return candidate;
}
if (wrapperLeadsTo(candidate, entrypoint, probe)) {
return candidate;
}
}
return undefined;
}
/** How many wrapper-to-wrapper hops to follow. Nix needs two; the cap is slack. */
const MAX_WRAPPER_HOPS = 4;
/** How many files one lookup may read before giving up, so a dense chain cannot run away. */
const MAX_WRAPPER_FILES = 32;
/** Absolute paths appearing in a script's text, stopping at shell quoting and separators. */
function absolutePathsIn(text: string): string[] {
return text.match(/\/[^\s"';|&()]+/g) ?? [];
}
/** Whether `start`, followed through the files it names, ends up naming `entrypoint`. */
function wrapperLeadsTo(start: string, entrypoint: string, probe: PathProbe): boolean {
const seen = new Set<string>();
let frontier = [start];
for (let hop = 0; hop <= MAX_WRAPPER_HOPS && frontier.length > 0; hop++) {
const next: string[] = [];
for (const file of frontier) {
if (seen.has(file) || seen.size >= MAX_WRAPPER_FILES) {
continue;
}
seen.add(file);
const text = probe.readText(file);
if (text === undefined) {
continue;
}
if (text.includes(entrypoint)) {
return true;
}
for (const referenced of absolutePathsIn(text)) {
if (!seen.has(referenced) && probe.realPath(referenced)) {
next.push(referenced);
}
}
}
frontier = next;
}
return false;
}
/**
* Anything much larger than a wrapper script is not one. The cap keeps a chain
* that happens to name `node` or `bash` from reading whole binaries back.
*/
const MAX_WRAPPER_BYTES = 64 * 1024;
const defaultPathProbe: PathProbe = {
realPath: (candidate) => {
try {
return statSync(candidate).isFile() ? realpathSync(candidate) : undefined;
} catch {
return undefined;
}
},
readText: (candidate) => {
try {
if (statSync(candidate).size > MAX_WRAPPER_BYTES) {
return undefined;
}
return readFileSync(candidate, "utf8");
} catch {
return undefined;
}
},
};
interface HookEntry {
command?: unknown;
}
interface HookGroup {
hooks?: unknown;
}
interface HookSettings {
hooks?: { SessionStart?: unknown };
}
/**
* Collapse repeated managed SessionStart entries down to the last one, which is
* the entry the SDK has just written or refreshed.
*
* The SDK recognises its own hook by testing whether the recorded command
* *contains* the marker, so an entrypoint path that happens not to contain
* "gitea-axi" makes a re-run append a second entry instead of updating the
* first — contradicting the idempotency `setup` promises. Recognition here does
* not depend on the command's shape: callers pass an `isManaged` that matches
* the exact command this run records, so a re-run identifies its own previous
* entry by equality rather than by a substring accident — and a hook belonging
* to another tool is never a candidate, whatever its command happens to spell.
*
* The *last* match survives. Every match holds the identical command, so the
* choice can only affect `type` and `timeout`, and the last is the entry the
* SDK has just appended in the case this exists to repair.
*
* Groups left with no hooks are dropped rather than kept as empty objects.
*/
export function pruneDuplicateManagedHooks(
settings: unknown,
isManaged: (command: string) => boolean,
): { settings: unknown; changed: boolean } {
const pruned = structuredClone(settings) as HookSettings | null;
const groups = pruned?.hooks?.SessionStart;
if (!Array.isArray(groups)) {
return { settings, changed: false };
}
const isManagedHook = (hook: HookEntry) =>
typeof hook?.command === "string" && isManaged(hook.command);
const managedCount = (groups as HookGroup[]).reduce(
(total, group) =>
total +
(Array.isArray(group?.hooks) ? (group.hooks as HookEntry[]).filter(isManagedHook).length : 0),
0,
);
if (managedCount < 2) {
return { settings, changed: false };
}
// Every managed entry but the last is a leftover from an earlier run.
let remaining = managedCount - 1;
const kept: HookGroup[] = [];
for (const group of groups as HookGroup[]) {
if (!Array.isArray(group?.hooks)) {
kept.push(group);
continue;
}
const survivors = (group.hooks as HookEntry[]).filter((hook) => {
if (remaining > 0 && isManagedHook(hook)) {
remaining--;
return false;
}
return true;
});
if (survivors.length > 0) {
group.hooks = survivors;
kept.push(group);
}
}
(pruned as HookSettings).hooks = { ...pruned?.hooks, SessionStart: kept };
return { settings: pruned, changed: true };
}

View File

@@ -18,14 +18,20 @@ export function formatCountLine(
shown: number,
total: number | undefined,
atLimit: boolean,
qualifier?: string,
): string {
// A generic, caller-supplied qualifier names what was counted (e.g. the state
// a list was filtered to) right after the count, so `count: 5 open of 5 total`
// answers "how many are open?" off the summary line. The helper stays unaware
// of any specific domain concept — callers that pass none render as before.
const counted = qualifier === undefined ? `${shown}` : `${shown} ${qualifier}`;
if (total === undefined) {
if (atLimit) {
return `count: ${shown} (showing first ${shown})`;
return `count: ${counted} (showing first ${shown})`;
}
return `count: ${shown} of ${shown} total`;
return `count: ${counted} of ${shown} total`;
}
return `count: ${shown} of ${total} total`;
return `count: ${counted} of ${total} total`;
}
/** Encode a named list block, with an explicit empty-state line when there are no rows. */

143
src/review-comments.ts Normal file
View File

@@ -0,0 +1,143 @@
import type { CreatePullReviewComment } from "gitea-js";
import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js";
import type { CliDeps } from "./deps.js";
import { anchorFromDiffHunk } from "./diff.js";
import { axiError } from "./errors.js";
import { readFlagFile } from "./flag-file.js";
import { flagValue } from "./flags.js";
import { fetchAllReviewComments } from "./review.js";
/**
* One entry from a `pr review --comments-file` JSON array, in one of two shapes.
* There is deliberately no `side` field: a new comment is always the new side,
* and a reply's side is inferred from the comment it targets.
*/
export type InlineCommentEntry =
| { reply_to: number; body: string }
| { path: string; line: number; body: string };
const COMMENTS_FILE_SUGGESTION = [
"Each entry must be `{ reply_to, body }` or `{ path, line, body }`",
];
/**
* Read and validate the `--comments-file` batch, if the flag is present. Returns
* `undefined` when it is absent, so a plain review is unaffected. Every failure —
* a missing/unreadable file, non-JSON, a non-array, or an entry matching neither
* shape — is a `VALIDATION_ERROR` raised here, before any request goes out.
*/
export function loadInlineComments(
deps: CliDeps,
flags: Record<string, string | true>,
command: string,
): InlineCommentEntry[] | undefined {
const path = flagValue(flags, "--comments-file");
if (path === undefined) {
return undefined;
}
return parseInlineComments(readCommentsFile(deps, path, command), command);
}
function readCommentsFile(deps: CliDeps, path: string, command: string): string {
return readFlagFile(deps, path, "--comments-file", [
`Run \`gitea-axi ${command} --comments-file <path>\` with a readable JSON file`,
]);
}
function parseInlineComments(text: string, command: string): InlineCommentEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw axiError(`--comments-file is not valid JSON: ${reason}`, "VALIDATION_ERROR");
}
if (!Array.isArray(parsed)) {
throw axiError(
"--comments-file must be a JSON array of inline-comment entries",
"VALIDATION_ERROR",
COMMENTS_FILE_SUGGESTION,
);
}
return parsed.map((entry, index) => validateEntry(entry, index));
}
function validateEntry(entry: unknown, index: number): InlineCommentEntry {
const at = `--comments-file entry ${index}`;
if (typeof entry !== "object" || entry === null) {
throw axiError(`${at} must be an object`, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION);
}
const record = entry as Record<string, unknown>;
if (typeof record.body !== "string") {
throw axiError(`${at} needs a string \`body\``, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION);
}
const isReply = record.reply_to !== undefined;
const isNew = record.path !== undefined || record.line !== undefined;
// The two shapes are exclusive: an entry carrying both a `reply_to` and a
// `path`/`line` is contradictory (a reply needs neither), so it is rejected
// rather than silently resolved to one arm.
if (isReply && isNew) {
throw axiError(
`${at} mixes a reply (\`reply_to\`) with a new comment (\`path\`/\`line\`) — use one shape`,
"VALIDATION_ERROR",
COMMENTS_FILE_SUGGESTION,
);
}
if (isReply) {
if (typeof record.reply_to !== "number") {
throw axiError(`${at} \`reply_to\` must be a number`, "VALIDATION_ERROR", COMMENTS_FILE_SUGGESTION);
}
return { reply_to: record.reply_to, body: record.body };
}
if (typeof record.path === "string" && typeof record.line === "number") {
return { path: record.path, line: record.line, body: record.body };
}
throw axiError(
`${at} must be a reply (\`reply_to\`) or a new comment (\`path\` + \`line\`)`,
"VALIDATION_ERROR",
COMMENTS_FILE_SUGGESTION,
);
}
/**
* Map validated inline-comment entries onto the review-submission payload's
* `comments[]`. A new comment goes straight through — its new-file `line`
* becomes `new_position` (always the new side). A reply is resolved against the
* PR's existing review comments (one reviews-plus-comments fan-out, only when a
* reply is present): its target is found by id, and that comment's anchor is
* reconstructed from its own `diff_hunk` so the reply threads onto the same
* line. A `reply_to` id absent from the PR is a `VALIDATION_ERROR`.
*/
export async function resolveInlineComments(
api: GiteaClient,
context: RepoContext,
number: number,
entries: InlineCommentEntry[],
): Promise<CreatePullReviewComment[]> {
const hasReply = entries.some((entry) => "reply_to" in entry);
const existing = hasReply ? await fetchAllReviewComments(api, context, number) : [];
return entries.map((entry) => {
if ("reply_to" in entry) {
const target = existing.find((comment) => comment.id === entry.reply_to);
if (target === undefined) {
throw axiError(
`--comments-file reply_to ${entry.reply_to} is not a review comment on this pull request`,
"VALIDATION_ERROR",
);
}
// The target's path and diff_hunk are what re-anchor the reply; a comment
// returned without them is a broken answer, not a reply we can invent an
// anchor for (mirroring the other "never fabricate" guards).
if (target.path === undefined || target.diff_hunk === undefined) {
throw axiError(
`Gitea returned review comment ${entry.reply_to} without the path/diff_hunk needed to anchor a reply`,
"UNKNOWN",
);
}
return { path: target.path, ...anchorFromDiffHunk(target.diff_hunk), body: entry.body };
}
return { path: entry.path, new_position: entry.line, body: entry.body };
});
}

View File

@@ -99,3 +99,25 @@ export async function fetchReviewComments(
throw classifyHttpError(error);
}
}
/**
* Every inline review comment on a PR, flattened across all its reviews. Gitea
* has no get-comment-by-id endpoint, so the write side's reply path uses this
* reviews-plus-comments fan-out — the same one `pr view --reviews` performs — to
* locate a reply's target comment by id.
*/
export async function fetchAllReviewComments(
api: GiteaClient,
context: RepoContext,
number: number,
): Promise<PullReviewComment[]> {
const reviews = await fetchReviews(api, context, number);
const lists = await Promise.all(
reviews.map((review) =>
review.id !== undefined
? fetchReviewComments(api, context, number, review.id)
: Promise.resolve<PullReviewComment[]>([]),
),
);
return lists.flat();
}

View File

@@ -1,4 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { resolveRepoContext } from "../src/context.js";
import type { CliDeps } from "../src/deps.js";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { runCliTest, testModeEnv } from "./harness.js";
@@ -117,3 +119,49 @@ describe("context overrides", () => {
expect(exitCode).toBe(0);
});
});
describe("apiUrl normalization", () => {
function depsWithApiUrl(apiUrl: string): CliDeps {
return {
env: {
GITEA_AXI_API_URL: apiUrl,
GITEA_AXI_REPO: "acme/widgets",
GITEA_AXI_TOKEN: "test-token",
},
cwd: process.cwd(),
globals: {},
};
}
it("strips a trailing /api/v1 suffix from the host base", async () => {
const context = await resolveRepoContext(
depsWithApiUrl("https://git.example.com/api/v1"),
);
expect(context.apiUrl).toBe("https://git.example.com");
});
it("strips a trailing /api/v1/ with a trailing slash", async () => {
const context = await resolveRepoContext(
depsWithApiUrl("https://git.example.com/api/v1/"),
);
expect(context.apiUrl).toBe("https://git.example.com");
});
it("leaves a host base without an /api/v1 suffix unchanged", async () => {
const context = await resolveRepoContext(
depsWithApiUrl("https://git.example.com"),
);
expect(context.apiUrl).toBe("https://git.example.com");
});
it("strips a lone trailing slash from the host base", async () => {
const context = await resolveRepoContext(
depsWithApiUrl("https://git.example.com/"),
);
expect(context.apiUrl).toBe("https://git.example.com");
});
});

View File

@@ -122,7 +122,7 @@ describe("repository context detection", () => {
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 3 of 3 total");
expect(stdout).toContain("count: 3 open of 3 total");
expect(server!.requests[0]!.headers.authorization).toBe("Bearer detected-token");
// Auto-detected context: suggestions must not carry override flags.
expect(stdout).not.toContain("-R testowner/testrepo");
@@ -143,7 +143,7 @@ describe("repository context detection", () => {
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 3 of 3 total");
expect(stdout).toContain("count: 3 open of 3 total");
});
it("fails with REPO_NOT_FOUND when there is no recognizable origin remote", async () => {

View File

@@ -40,7 +40,7 @@ describe.skipIf(!E2E_URL)("end-to-end: tracer command set", () => {
expect(exitCode).toBe(0);
const lines = stdout.split("\n");
expect(lines[0]).toBe("count: 3 of 3 total");
expect(lines[0]).toBe("count: 3 open of 3 total");
expect(lines[1]).toBe("issues[3]{number,title,state,author,created}:");
for (const title of instance.openTitles) {
expect(stdout).toContain(title);
@@ -58,7 +58,7 @@ describe.skipIf(!E2E_URL)("end-to-end: tracer command set", () => {
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 1 of 1 total");
expect(stdout).toContain("count: 1 closed of 1 total");
expect(stdout).toContain(instance.closedTitle);
expect(stdout).toContain(",closed,");
});
@@ -69,7 +69,7 @@ describe.skipIf(!E2E_URL)("end-to-end: tracer command set", () => {
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 1 of 3 total");
expect(stdout).toContain("count: 1 open of 3 total");
expect(stdout).toContain("issues[1]{number,title,state,author,created}:");
expect(stdout).toContain("issue list --limit <n>");
});

263
test/hooks.test.ts Normal file
View File

@@ -0,0 +1,263 @@
import { delimiter } from "node:path";
import { describe, expect, it } from "vitest";
import {
type PathProbe,
pruneDuplicateManagedHooks,
resolveEntrypointOnPath,
} from "../src/hooks.js";
describe("resolveEntrypointOnPath", () => {
const ENTRYPOINT = "/opt/gitea-axi/dist/main.js";
/** A probe over a fake filesystem: real paths, plus text for wrapper scripts. */
const probeOver = (
realPaths: Record<string, string>,
texts: Record<string, string> = {},
): PathProbe => ({
realPath: (candidate) => realPaths[candidate],
readText: (candidate) => texts[candidate],
});
const path = ["/empty", "/usr/local/bin", "/usr/bin"].join(delimiter);
it("matches a symlink to the entrypoint by real path — npm's install shape", () => {
const probe = probeOver({
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": ENTRYPOINT,
});
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
"/usr/local/bin/gitea-axi",
);
});
it("matches a wrapper naming the entrypoint in its text — the Nix install shape", () => {
// The wrapper's own realpath is the wrapper, never the entrypoint, which is
// precisely why the realpath test alone cannot see this install.
const probe = probeOver(
{
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
},
{ "/usr/local/bin/gitea-axi": `#!/bin/sh\nexec node ${ENTRYPOINT} "$@"\n` },
);
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
"/usr/local/bin/gitea-axi",
);
});
it("follows a chained wrapper to the entrypoint — the real Nix shape", () => {
// Nix is two hops: bin/gitea-axi sets PATH and execs bin/.gitea-axi-wrapped,
// and only that second script names the entrypoint.
const wrapped = "/usr/local/bin/.gitea-axi-wrapped";
const probe = probeOver(
{
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
[wrapped]: wrapped,
},
{
"/usr/local/bin/gitea-axi": `#!/bin/bash -e\nexport PATH\nexec -a "$0" "${wrapped}" "$@"\n`,
[wrapped]: `#!/bin/bash -e\nexec "/usr/bin/node" ${ENTRYPOINT} "$@"\n`,
},
);
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
"/usr/local/bin/gitea-axi",
);
});
it("gives up on a wrapper chain that never names the entrypoint", () => {
// A cycle: it must terminate rather than following the loop forever.
const other = "/usr/local/bin/other";
const probe = probeOver(
{
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
[other]: other,
},
{
"/usr/local/bin/gitea-axi": `exec "${other}"\n`,
[other]: `exec "/usr/local/bin/gitea-axi"\n`,
},
);
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
});
it("refuses a same-named binary that is some other program", () => {
// Accepting this would hand the SDK a path that realpath-matches itself,
// making its check a tautology and recording a name for a program the
// caller never asked about.
const probe = probeOver(
{
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": "/somewhere/else/gitea-axi",
},
{ "/usr/local/bin/gitea-axi": "#!/bin/sh\nexec node /somewhere/else/dist/main.js\n" },
);
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
});
it("returns undefined when the name resolves nowhere on PATH", () => {
const probe = probeOver({ [ENTRYPOINT]: ENTRYPOINT });
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
});
it("returns undefined for an unset or empty PATH", () => {
const probe = probeOver({ [ENTRYPOINT]: ENTRYPOINT });
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, undefined, probe)).toBeUndefined();
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, "", probe)).toBeUndefined();
});
it("skips empty PATH entries rather than probing the working directory", () => {
const probed: string[] = [];
resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, ["", "/usr/bin", ""].join(delimiter), {
realPath: (candidate) => {
probed.push(candidate);
return undefined;
},
readText: () => undefined,
});
expect(probed).toEqual([ENTRYPOINT, "/usr/bin/gitea-axi"]);
});
it("takes the first PATH entry that matches, not a later one", () => {
const probe = probeOver({
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": ENTRYPOINT,
"/usr/bin/gitea-axi": ENTRYPOINT,
});
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
"/usr/local/bin/gitea-axi",
);
});
});
describe("pruneDuplicateManagedHooks", () => {
const settingsWith = (...commands: string[]) => ({
hooks: {
SessionStart: commands.map((command) => ({
matcher: "",
hooks: [{ type: "command", command, timeout: 10 }],
})),
},
});
interface ReadBack {
hooks: { SessionStart: { hooks: { command: string; timeout?: number }[] }[] };
}
const commandsOf = (settings: unknown) =>
(settings as ReadBack).hooks.SessionStart.flatMap((group) =>
group.hooks.map((hook) => hook.command),
);
it("collapses a duplicated entry whose command does not contain the marker", () => {
// The case the SDK cannot handle: it recognises its own hook by finding the
// marker inside the recorded command, so an entrypoint path without
// "gitea-axi" in it makes a re-run append rather than update.
const entrypoint = "/build/source/dist/main.js";
const result = pruneDuplicateManagedHooks(
settingsWith(entrypoint, entrypoint),
(command) => command === entrypoint,
);
expect(result.changed).toBe(true);
expect(commandsOf(result.settings)).toEqual([entrypoint]);
});
it("keeps the last managed entry, which is the one the SDK just appended", () => {
// Both entries carry the same command, so the survivor is identified by the
// rest of its shape: the stale one has a timeout the SDK no longer writes.
const result = pruneDuplicateManagedHooks(
{
hooks: {
SessionStart: [
{ matcher: "", hooks: [{ type: "command", command: "gitea-axi", timeout: 99 }] },
{ matcher: "", hooks: [{ type: "command", command: "gitea-axi", timeout: 10 }] },
],
},
},
(command) => command === "gitea-axi",
);
const settings = result.settings as ReadBack;
expect(settings.hooks.SessionStart).toHaveLength(1);
expect(settings.hooks.SessionStart[0]?.hooks[0]).toMatchObject({ timeout: 10 });
});
it("leaves hooks belonging to other tools untouched", () => {
const result = pruneDuplicateManagedHooks(
settingsWith("other-tool", "gitea-axi", "another-tool", "gitea-axi"),
(command) => command === "gitea-axi",
);
expect(commandsOf(result.settings)).toEqual(["other-tool", "another-tool", "gitea-axi"]);
});
it("prunes duplicates that share a single group", () => {
const result = pruneDuplicateManagedHooks(
{
hooks: {
SessionStart: [
{
matcher: "",
hooks: [
{ type: "command", command: "gitea-axi", timeout: 10 },
{ type: "command", command: "other-tool", timeout: 10 },
{ type: "command", command: "gitea-axi", timeout: 10 },
],
},
],
},
},
(command) => command === "gitea-axi",
);
expect(commandsOf(result.settings)).toEqual(["other-tool", "gitea-axi"]);
});
it("reports no change and returns the input when there is nothing to prune", () => {
const single = settingsWith("gitea-axi");
const result = pruneDuplicateManagedHooks(single, (command) => command === "gitea-axi");
expect(result.changed).toBe(false);
expect(result.settings).toBe(single);
});
it("preserves sibling settings keys and other hook events", () => {
const result = pruneDuplicateManagedHooks(
{
model: "opus",
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "audit" }] }],
SessionStart: settingsWith("gitea-axi", "gitea-axi").hooks.SessionStart,
},
},
(command) => command === "gitea-axi",
);
const settings = result.settings as ReadBack & {
model: string;
hooks: { PreToolUse: unknown[] };
};
expect(settings.model).toBe("opus");
expect(settings.hooks.PreToolUse).toHaveLength(1);
expect(commandsOf(settings)).toEqual(["gitea-axi"]);
});
it("tolerates settings with no SessionStart hooks at all", () => {
for (const input of [{}, { hooks: {} }, { hooks: { SessionStart: "nonsense" } }, null]) {
const result = pruneDuplicateManagedHooks(input, () => true);
expect(result.changed).toBe(false);
expect(result.settings).toBe(input);
}
});
});

View File

@@ -62,7 +62,7 @@ describe("issue list", () => {
expect(exitCode).toBe(0);
const lines = stdout.split("\n");
expect(lines[0]).toBe("count: 3 of 17 total");
expect(lines[0]).toBe("count: 3 open of 17 total");
expect(lines[1]).toBe("issues[3]{number,title,state,author,created}:");
expect(lines[2]).toMatch(/^ {2}42,"Fix login redirect loop, please",open,alexion,\d+(mo|[smhdy]) ago$/);
expect(lines[3]).toMatch(/^ {2}41,Add dark mode,open,contributor,\d+(mo|[smhdy]) ago$/);
@@ -105,7 +105,7 @@ describe("issue list", () => {
);
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 1 of 1 total");
expect(stdout).toContain("count: 1 closed of 1 total");
expect(stdout).toContain("37,Crash on empty config,closed,contributor");
});
@@ -133,7 +133,7 @@ describe("issue list", () => {
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 0 of 0 total");
expect(stdout).toContain("count: 0 open of 0 total");
expect(stdout).toContain("issues[0]: (none)");
expect(stdout).toMatch(/^help\[\d+\]:/m);
});
@@ -209,6 +209,28 @@ describe("issue list", () => {
expect(stdout).toContain("issues[3]{number,title,state,author,created}:");
expect(stdout).not.toContain("type");
});
it("names no state in the count line for --state all", async () => {
// `all` imposes no narrowing filter, so the count line stays the plain
// `count: <shown> of <total> total` form with no state qualifier.
const issues = Array.from({ length: 9 }, (_, i) => issueOf(i + 1));
server = await startFixtureServer([
{
method: "GET",
path: ISSUES_PATH,
query: { state: "all" },
headers: { "X-Total-Count": "9" },
body: issues,
},
]);
const { stdout, exitCode } = await runCliTest(
["issue", "list", "--state", "all"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(stdout.split("\n")[0]).toBe("count: 9 of 9 total");
});
});
describe("issue list filters", () => {
@@ -375,7 +397,7 @@ describe("issue list --sort", () => {
expect(renderedNumbers(stdout)[0]).toBe(7);
// The count line keeps T from X-Total-Count: sorting reorders without
// changing membership, so the unfiltered total stays accurate (ADR 0005).
expect(stdout).toContain("count: 30 of 52 total");
expect(stdout).toContain("count: 30 open of 52 total");
});
it("applies --limit to the sorted order, not to the fetched pages", async () => {
@@ -393,7 +415,7 @@ describe("issue list --sort", () => {
);
expect(renderedNumbers(stdout)).toEqual([38, 42]);
expect(stdout).toContain("count: 2 of 17 total");
expect(stdout).toContain("count: 2 open of 17 total");
// Pagination reads full pages regardless of --limit; the cap is applied after sorting.
expect(server.requests[0]!.query.limit).toBe("50");
});
@@ -408,7 +430,7 @@ describe("issue list --sort", () => {
env: testModeEnv(server.url),
});
expect(stdout).toContain("count: 2 of 3 total");
expect(stdout).toContain("count: 2 open of 3 total");
});
it("stops at the page cap when a server keeps returning full pages", async () => {

View File

@@ -47,6 +47,46 @@ describe("issue view", () => {
expect(stdout).toContain("comment_count: 3 — use --comments to see full comments");
});
it("renders the issue's labels comma-joined by default, with no flag", async () => {
server = await startFixtureServer([
{
method: "GET",
path: ISSUE_PATH,
body: issueBody({ labels: [{ name: "bug" }, { name: "regression" }] }),
},
]);
const { stdout, exitCode } = await runCliTest(["issue", "view", "42"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
// TOON-quoted because the joined value contains a comma.
expect(stdout).toContain('labels: "bug, regression"');
});
it("appends named extra fields with --fields on top of the default fields", async () => {
server = await startFixtureServer([
{
method: "GET",
path: ISSUE_PATH,
body: issueBody({
assignees: [{ login: "alexion" }],
milestone: { title: "v2.0" },
}),
},
]);
const { stdout, exitCode } = await runCliTest(
["issue", "view", "42", "--fields", "assignees,milestone"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
// Default fields are still present; the extra fields are appended.
expect(stdout).toContain("state: open");
expect(stdout).toContain("assignees: alexion");
expect(stdout).toContain("milestone: v2.0");
});
it("renders comment_count: 0 when there are no comments", async () => {
server = await startFixtureServer([
{ method: "GET", path: ISSUE_PATH, body: issueBody({ comments: 0 }) },

View File

@@ -0,0 +1,118 @@
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { startFixtureServer } from "../fixture-server.js";
import { installGlobally, packTarball } from "./npm-artifact.js";
// What an *installed* gitea-axi must do, whatever installed it: run, render
// against a Gitea, and install its Agent Skill.
//
// The binary under test comes from the environment when GITEA_AXI_INSTALLED_BIN
// names one, and otherwise from packing and globally installing the npm tarball
// right here. That makes this one seam with two callers — the npm distribution
// path and the Nix installation path — so the two cannot drift apart in what
// they guarantee.
//
// Consequently nothing below may assert on how the binary came to exist: no
// store paths, no wrapper internals, no arrangement of files within the
// installed tree. Those are implementation detail of the installation method.
/**
* Set by a caller that has already installed gitea-axi and wants that one
* driven. An empty value counts as unset, so exporting it blank is the same as
* not exporting it at all.
*/
const providedBin = process.env.GITEA_AXI_INSTALLED_BIN || undefined;
let workDir: string | undefined;
let binPath: string;
// Unlike the in-process CLI-seam harness (test/harness.ts), which keeps the
// environment fully explicit so nothing leaks in, this tier spawns a real
// installed binary as a subprocess. That subprocess genuinely needs the parent
// env (PATH to resolve node/git/tea, npm config, etc.), so both helpers inherit
// `process.env` on purpose and layer the per-call `env` on top.
/** Run the installed binary, returning its stdout. */
function run(args: string[], env: Record<string, string> = {}): string {
return execFileSync(binPath, args, { encoding: "utf8", env: { ...process.env, ...env } });
}
const execFileAsync = promisify(execFile);
/**
* Run the installed binary without blocking this process's event loop, so an
* in-process fixture server can answer the CLI's HTTP calls while it runs. (A
* synchronous `execFileSync` would freeze the loop the fixture server lives on,
* deadlocking the request/response.)
*/
async function runAsync(args: string[], env: Record<string, string> = {}): Promise<string> {
const { stdout } = await execFileAsync(binPath, args, {
encoding: "utf8",
env: { ...process.env, ...env },
});
return stdout;
}
beforeAll(() => {
if (providedBin !== undefined) {
// Fail here rather than letting every assertion fail on a confusing ENOENT
// from the spawn.
if (!existsSync(providedBin)) {
throw new Error(
`GITEA_AXI_INSTALLED_BIN points at ${providedBin}, which does not exist. ` +
"Unset it to have this tier pack and install the npm tarball itself.",
);
}
binPath = providedBin;
return;
}
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-install-"));
binPath = installGlobally(packTarball(workDir), workDir);
}, 300_000);
afterAll(() => {
if (workDir) {
rmSync(workDir, { recursive: true, force: true });
}
});
describe("installed gitea-axi binary", () => {
it("is executable and prints its usage", () => {
expect(existsSync(binPath)).toBe(true);
expect(run(["--help"])).toContain("usage: gitea-axi");
});
it("renders the dashboard header against a Gitea", async () => {
const server = await startFixtureServer([
{ method: "GET", path: "/api/v1/repos/o/r/pulls", body: [] },
{ method: "GET", path: "/api/v1/repos/o/r/issues", body: [] },
]);
try {
const dashboard = await runAsync([], {
GITEA_AXI_API_URL: server.url,
GITEA_AXI_REPO: "o/r",
GITEA_AXI_TOKEN: "x",
});
expect(dashboard).toContain("bin:");
expect(dashboard).toContain("description: Agent-ergonomic CLI for Gitea");
expect(dashboard).toContain("repo: o/r");
} finally {
await server.close();
}
});
it("finds its bundled Agent Skill and installs it into HOME/.claude", () => {
const home = mkdtempSync(join(tmpdir(), "gitea-axi-home-"));
try {
expect(run(["setup"], { HOME: home })).toContain("status: installed");
expect(existsSync(join(home, ".claude", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,53 @@
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
// Building the npm distribution artifact, factored out so the two packaging
// facets can each take just the part they need: the tarball facet packs and
// extracts, the installed-binary facet packs and installs.
export const projectRoot = fileURLToPath(new URL("../..", import.meta.url));
/**
* Pack the real tarball into `destDir`, returning its path.
*
* `npm pack --json` reports the tarball filename; the shape shifted across npm
* majors (an array of entries on npm <12, an object keyed by package name on
* npm 12+), so accept either and pull the one filename out.
*/
export function packTarball(destDir: string): string {
const packOutput = execFileSync("npm", ["pack", "--json", "--pack-destination", destDir], {
cwd: projectRoot,
encoding: "utf8",
});
const packResult = JSON.parse(packOutput) as unknown;
const packEntries = Array.isArray(packResult)
? (packResult as Array<{ filename: string }>)
: Object.values(packResult as Record<string, { filename: string }>);
return join(destDir, packEntries[0]!.filename);
}
/**
* Extract `tarball` into a fresh `extract/` under `destDir`, returning the
* directory npm nests everything under (`<destDir>/extract/package`).
*/
export function extractTarball(tarball: string, destDir: string): string {
const extractDir = join(destDir, "extract");
mkdirSync(extractDir);
execFileSync("tar", ["-xzf", tarball, "-C", extractDir]);
return join(extractDir, "package");
}
/**
* Install `tarball` globally into a throwaway prefix under `destDir`, returning
* the path of the installed binary on that prefix's bin dir.
*/
export function installGlobally(tarball: string, destDir: string): string {
const prefix = join(destDir, "prefix");
execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], {
cwd: projectRoot,
encoding: "utf8",
});
return join(prefix, "bin", "gitea-axi");
}

View File

@@ -1,176 +0,0 @@
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { startFixtureServer } from "../fixture-server.js";
// The one artifact under test: the packed npm tarball, installed globally into a
// throwaway prefix. Packing plus a global install is expensive and identical for
// every facet, so it is built once here and the four facets assert against the
// shared result.
const projectRoot = fileURLToPath(new URL("../..", import.meta.url));
let workDir: string;
let extractDir: string;
let binPath: string;
/** The manifest as it ships inside the packed tarball. */
let packedManifest: Record<string, unknown>;
// Unlike the in-process CLI-seam harness (test/harness.ts), which keeps the
// environment fully explicit so nothing leaks in, this tier spawns a real
// globally-installed binary as a subprocess. That subprocess genuinely needs the
// parent env (PATH to resolve node/npm/tar, npm config, etc.), so both helpers
// inherit `process.env` on purpose and layer the per-call `env` on top.
/** Run the globally installed binary, returning its stdout. */
function run(args: string[], env: Record<string, string> = {}): string {
return execFileSync(binPath, args, { encoding: "utf8", env: { ...process.env, ...env } });
}
const execFileAsync = promisify(execFile);
/**
* Run the installed binary without blocking this process's event loop, so an
* in-process fixture server can answer the CLI's HTTP calls while it runs. (A
* synchronous `execFileSync` would freeze the loop the fixture server lives on,
* deadlocking the request/response.)
*/
async function runAsync(args: string[], env: Record<string, string> = {}): Promise<string> {
const { stdout } = await execFileAsync(binPath, args, {
encoding: "utf8",
env: { ...process.env, ...env },
});
return stdout;
}
beforeAll(() => {
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-pack-"));
// Pack the real tarball. `npm pack --json` reports the tarball filename; the
// shape shifted across npm majors (an array of entries on npm <12, an object
// keyed by package name on npm 12+), so accept either and pull the one
// filename out.
const packOutput = execFileSync(
"npm",
["pack", "--json", "--pack-destination", workDir],
{ cwd: projectRoot, encoding: "utf8" },
);
const packResult = JSON.parse(packOutput) as unknown;
const packEntries = Array.isArray(packResult)
? (packResult as Array<{ filename: string }>)
: Object.values(packResult as Record<string, { filename: string }>);
const tarball = join(workDir, packEntries[0]!.filename);
// Extract to inspect the packed manifest and confirm bundled files. npm nests
// everything under `package/`.
extractDir = join(workDir, "extract");
mkdirSync(extractDir);
execFileSync("tar", ["-xzf", tarball, "-C", extractDir]);
packedManifest = JSON.parse(
readFileSync(join(extractDir, "package", "package.json"), "utf8"),
) as Record<string, unknown>;
// Install globally into a throwaway prefix so the binary lands on a PATH-like
// bin dir we control.
const prefix = join(workDir, "prefix");
execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], {
cwd: projectRoot,
encoding: "utf8",
});
binPath = join(prefix, "bin", "gitea-axi");
}, 300_000);
afterAll(() => {
if (workDir) {
rmSync(workDir, { recursive: true, force: true });
}
});
describe("npm distribution artifact", () => {
it("bundles the built CLI, the bin entry, and the Agent Skill, and declares no postinstall", () => {
expect(existsSync(join(extractDir, "package", "dist", "main.js"))).toBe(true);
expect(existsSync(join(extractDir, "package", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
const bin = packedManifest.bin as Record<string, string> | undefined;
expect(bin?.["gitea-axi"]).toBe("dist/main.js");
const scripts = packedManifest.scripts as Record<string, string> | undefined;
expect(scripts?.postinstall).toBeUndefined();
});
it("excludes the bench/ harness directory from the package", () => {
expect(existsSync(join(extractDir, "package", "bench"))).toBe(false);
});
it("declares complete metadata: unscoped name, description, repo, license, engines, ESM type", () => {
const name = packedManifest.name as string;
expect(name).toBe("gitea-axi");
expect(name).not.toContain("@");
expect(name).not.toContain("/");
const description = packedManifest.description as string;
expect(typeof description).toBe("string");
expect(description.length).toBeGreaterThan(0);
const repository = packedManifest.repository as string | { url?: string } | undefined;
const repositoryUrl = typeof repository === "string" ? repository : repository?.url;
expect(repositoryUrl).toContain("gitea-axi");
expect(packedManifest.license).toBe("MIT");
const engines = packedManifest.engines as Record<string, string> | undefined;
expect(engines?.node).toMatch(/20/);
expect(packedManifest.type).toBe("module");
});
it("scripts and documents the publish flow so publishing is a single command", () => {
const scripts = packedManifest.scripts as Record<string, string> | undefined;
expect(scripts?.prepack).toBe("npm run build");
const publishConfig = packedManifest.publishConfig as Record<string, string> | undefined;
expect(publishConfig?.access).toBe("public");
const publishingDoc = join(projectRoot, "PUBLISHING.md");
expect(existsSync(publishingDoc)).toBe(true);
expect(readFileSync(publishingDoc, "utf8")).toMatch(/npm publish/);
});
it("puts a working gitea-axi on the PATH: dashboard header, --help, and setup all function", async () => {
expect(existsSync(binPath)).toBe(true);
expect(run(["--help"])).toContain("usage: gitea-axi");
// Dashboard header renders against a stubbed Gitea.
const server = await startFixtureServer([
{ method: "GET", path: "/api/v1/repos/o/r/pulls", body: [] },
{ method: "GET", path: "/api/v1/repos/o/r/issues", body: [] },
]);
try {
const dashboard = await runAsync([], {
GITEA_AXI_API_URL: server.url,
GITEA_AXI_REPO: "o/r",
GITEA_AXI_TOKEN: "x",
});
expect(dashboard).toContain("bin:");
expect(dashboard).toContain("description: Agent-ergonomic CLI for Gitea");
expect(dashboard).toContain("repo: o/r");
} finally {
await server.close();
}
// `setup` installs the Agent Skill into HOME/.claude.
const home = mkdtempSync(join(tmpdir(), "gitea-axi-home-"));
try {
expect(run(["setup"], { HOME: home })).toContain("status: installed");
expect(existsSync(join(home, ".claude", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,88 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { extractTarball, packTarball, projectRoot } from "./npm-artifact.js";
// The shape of the packed npm tarball and the manifest that ships inside it.
// These assertions are npm-specific by nature — no other distribution method
// produces a tarball or a packed manifest — so they stay separate from the
// installed-binary assertions, which any installation method can drive.
let workDir: string;
/** The extracted tarball's root — npm nests everything under `package/`. */
let packageDir: string;
/** The manifest as it ships inside the packed tarball. */
let packedManifest: Record<string, unknown>;
beforeAll(() => {
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-pack-"));
const tarball = packTarball(workDir);
packageDir = extractTarball(tarball, workDir);
packedManifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) as Record<
string,
unknown
>;
}, 300_000);
afterAll(() => {
if (workDir) {
rmSync(workDir, { recursive: true, force: true });
}
});
describe("npm distribution artifact", () => {
it("bundles the built CLI, the bin entry, and the Agent Skill, and declares no postinstall", () => {
expect(existsSync(join(packageDir, "dist", "main.js"))).toBe(true);
expect(existsSync(join(packageDir, "skills", "gitea-axi", "SKILL.md"))).toBe(true);
const bin = packedManifest.bin as Record<string, string> | undefined;
expect(bin?.["gitea-axi"]).toBe("dist/main.js");
const scripts = packedManifest.scripts as Record<string, string> | undefined;
expect(scripts?.postinstall).toBeUndefined();
});
it("excludes the bench/ harness directory from the package", () => {
expect(existsSync(join(packageDir, "bench"))).toBe(false);
});
it("declares complete metadata: unscoped name, description, repo, license, engines, ESM type", () => {
const name = packedManifest.name as string;
expect(name).toBe("gitea-axi");
expect(name).not.toContain("@");
expect(name).not.toContain("/");
const description = packedManifest.description as string;
expect(typeof description).toBe("string");
expect(description.length).toBeGreaterThan(0);
const repository = packedManifest.repository as string | { url?: string } | undefined;
const repositoryUrl = typeof repository === "string" ? repository : repository?.url;
expect(repositoryUrl).toContain("gitea-axi");
expect(packedManifest.license).toBe("MIT");
// The declared range names exactly the majors continuous integration
// matrixes over, so nothing is promised that is never tested. Node 20 is
// end-of-life and deliberately no longer named.
const engines = packedManifest.engines as Record<string, string> | undefined;
expect(engines?.node).toMatch(/22/);
expect(engines?.node).toMatch(/24/);
expect(engines?.node).not.toMatch(/20/);
expect(packedManifest.type).toBe("module");
});
it("scripts and documents the publish flow so publishing is a single command", () => {
const scripts = packedManifest.scripts as Record<string, string> | undefined;
expect(scripts?.prepack).toBe("npm run build");
const publishConfig = packedManifest.publishConfig as Record<string, string> | undefined;
expect(publishConfig?.access).toBe("public");
const publishingDoc = join(projectRoot, "PUBLISHING.md");
expect(existsSync(publishingDoc)).toBe(true);
expect(readFileSync(publishingDoc, "utf8")).toMatch(/npm publish/);
});
});

View File

@@ -110,6 +110,187 @@ describe("pr review", () => {
expect(reviewPosted()?.body).toEqual({ event: "COMMENT", body: "Looks good" });
});
it("maps a --comments-file new-comment entry to comments[] with new_position and reports the count", async () => {
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
const path = files.write(
"comments.json",
JSON.stringify([{ path: "src/x.ts", line: 42, body: "fresh point" }]),
);
const { stdout, exitCode } = await runCliTest(
["pr", "review", "9", "--comment", "--comments-file", path],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(reviewPosted()?.body).toEqual({
event: "COMMENT",
comments: [{ path: "src/x.ts", new_position: 42, body: "fresh point" }],
});
expect(stdout).toContain("action: comment");
expect(stdout).toContain("number: 9");
expect(stdout).toContain("comments: 1");
});
it("reconstructs a reply's anchor from the target comment's diff_hunk via the reviews fan-out", async () => {
server = await startFixtureServer([
{ method: "POST", path: REVIEWS_PATH, body: {} },
{
method: "GET",
path: REVIEWS_PATH,
body: [{ id: 30, state: "COMMENT", user: { login: "rev" } }],
},
{
method: "GET",
path: `${REVIEWS_PATH}/30/comments`,
body: [
{
id: 500,
path: "src/a.ts",
diff_hunk: "@@ -10,3 +10,4 @@\n ctxA\n ctxB\n+added",
body: "orig",
},
],
},
]);
const path = files.write(
"comments.json",
JSON.stringify([{ reply_to: 500, body: "my reply" }]),
);
const { stdout, exitCode } = await runCliTest(
["pr", "review", "9", "--comment", "--comments-file", path],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(reviewPosted()?.body).toEqual({
event: "COMMENT",
comments: [{ path: "src/a.ts", new_position: 12, body: "my reply" }],
});
expect(stdout).toContain("comments: 1");
});
it("rejects a reply whose reply_to id is not among the PR's review comments without posting", async () => {
server = await startFixtureServer([
{ method: "POST", path: REVIEWS_PATH, body: {} },
{
method: "GET",
path: REVIEWS_PATH,
body: [{ id: 30, state: "COMMENT", user: { login: "rev" } }],
},
{
method: "GET",
path: `${REVIEWS_PATH}/30/comments`,
body: [
{
id: 500,
path: "src/a.ts",
diff_hunk: "@@ -10,3 +10,4 @@\n ctxA\n ctxB\n+added",
body: "orig",
},
],
},
]);
const path = files.write(
"comments.json",
JSON.stringify([{ reply_to: 999, body: "reply to nobody" }]),
);
const { stdout, exitCode } = await runCliTest(
["pr", "review", "9", "--comment", "--comments-file", path],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(reviewPosted()).toBeUndefined();
});
it("composes a top-level --body with the inline comments batch in one payload", async () => {
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
const path = files.write(
"comments.json",
JSON.stringify([{ path: "src/y.ts", line: 7, body: "inline note" }]),
);
const { exitCode } = await runCliTest(
[
"pr",
"review",
"9",
"--request-changes",
"--body",
"overall: please fix",
"--comments-file",
path,
],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(reviewPosted()?.body).toEqual({
event: "REQUEST_CHANGES",
body: "overall: please fix",
comments: [{ path: "src/y.ts", new_position: 7, body: "inline note" }],
});
});
it("anchors a reply on a deleted line to old_position inferred from the target", async () => {
server = await startFixtureServer([
{ method: "POST", path: REVIEWS_PATH, body: {} },
{
method: "GET",
path: REVIEWS_PATH,
body: [{ id: 40, state: "COMMENT", user: { login: "rev" } }],
},
{
method: "GET",
path: `${REVIEWS_PATH}/40/comments`,
body: [
{
id: 700,
path: "src/b.ts",
diff_hunk: "@@ -20,2 +20,1 @@\n ctx1\n-removed",
body: "orig",
},
],
},
]);
const path = files.write(
"comments.json",
JSON.stringify([{ reply_to: 700, body: "reply on deletion" }]),
);
const { exitCode } = await runCliTest(
["pr", "review", "9", "--comment", "--comments-file", path],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
expect(reviewPosted()?.body).toEqual({
event: "COMMENT",
comments: [{ path: "src/b.ts", old_position: 21, body: "reply on deletion" }],
});
});
it("rejects a comments-file entry mixing reply_to with path/line before any API call", async () => {
server = await startFixtureServer([]);
const path = files.write(
"comments.json",
JSON.stringify([{ reply_to: 500, path: "src/a.ts", line: 3, body: "confused entry" }]),
);
const { stdout, exitCode } = await runCliTest(
["pr", "review", "9", "--comment", "--comments-file", path],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
expect(server.requests).toHaveLength(0);
});
it("forwards a --body-file review body from the file contents", async () => {
server = await startFixtureServer([{ method: "POST", path: REVIEWS_PATH, body: {} }]);
const path = files.write("review.txt", "Please fix the tests");

View File

@@ -169,7 +169,17 @@ describe("pr view", () => {
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/7/reviews/11/comments",
body: [{ user: { login: "alice" }, path: "src/x.ts", body: "nit here" }],
body: [
{
id: 501,
user: { login: "alice" },
path: "src/x.ts",
body: "nit here",
diff_hunk: "@@ -10,6 +10,7 @@ func main\n a := 1\n b := 2\n c := 3\n d := 4\n+e := 5",
position: 7,
original_position: 4,
},
],
},
{
method: "GET",
@@ -194,12 +204,253 @@ describe("pr view", () => {
expect(stdout).toContain(" official: no");
expect(stdout).toContain(" stale: no");
expect(stdout).toContain(" stale: yes");
expect(stdout).toContain(" comments[1]{author,path,body}:");
expect(stdout).toContain(" alice,src/x.ts,nit here");
expect(stdout).toContain(" comments[1]{id,author,path,resolved,diff_hunk,body}:");
expect(stdout).toContain(
' 501,alice,src/x.ts,no,"@@ -10,6 +10,7 @@ func main\\n d := 4\\n+e := 5",nit here',
);
expect(stdout).not.toContain("original_position");
expect(stdout).not.toContain("position:");
expect(stdout).not.toContain("review_count");
expect(stdout).toContain("comment_count");
});
it("renders resolved as yes when an inline comment's resolver is populated", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/8",
body: {
number: 8,
title: "T",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 0,
body: "b",
head: { sha: "sha8", ref: "f" },
},
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/8/reviews",
body: [
{
id: 21,
state: "COMMENT",
official: false,
stale: false,
dismissed: false,
user: { login: "dave" },
body: "",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/8/reviews/21/comments",
body: [
{
id: 777,
user: { login: "dave" },
path: "a.ts",
resolver: { login: "carol" },
diff_hunk: "@@ -1,4 +1,5 @@\n a\n b\n c\n+d",
body: "ok",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/commits/sha8/status",
body: { sha: "sha8", total_count: 0, statuses: [] },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "8", "--reviews"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain('777,dave,a.ts,yes,"@@ -1,4 +1,5 @@\\n c\\n+d",ok');
});
it("emits each inline comment's diff_hunk verbatim under --full, with no trimming", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/10",
body: {
number: 10,
title: "T",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 0,
body: "b",
head: { sha: "sha10", ref: "f" },
},
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/10/reviews",
body: [
{
id: 41,
state: "COMMENT",
official: false,
stale: false,
dismissed: false,
user: { login: "frank" },
body: "",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/10/reviews/41/comments",
body: [
{
id: 999,
user: { login: "frank" },
path: "big.ts",
diff_hunk: "@@ -1,4 +1,5 @@\n a\n b\n c\n+d",
body: "hi",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/commits/sha10/status",
body: { sha: "sha10", total_count: 0, statuses: [] },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "10", "--reviews", "--full"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain('999,frank,big.ts,no,"@@ -1,4 +1,5 @@\\n a\\n b\\n c\\n+d",hi');
});
it("renders a diff_hunk of three lines or fewer in full by default, leaving short hunks untrimmed", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/9",
body: {
number: 9,
title: "T",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 0,
body: "b",
head: { sha: "sha9", ref: "f" },
},
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/9/reviews",
body: [
{
id: 31,
state: "COMMENT",
official: false,
stale: false,
dismissed: false,
user: { login: "eve" },
body: "",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/9/reviews/31/comments",
body: [
{
id: 888,
user: { login: "eve" },
path: "b.ts",
diff_hunk: "@@ -5 +5 @@\n-x\n+y",
body: "short",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/commits/sha9/status",
body: { sha: "sha9", total_count: 0, statuses: [] },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "9", "--reviews"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain('888,eve,b.ts,no,"@@ -5 +5 @@\\n-x\\n+y",short');
});
it("trims a 4-line diff_hunk to its header plus last two lines by default, dropping the one middle line", async () => {
server = await startFixtureServer([
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/11",
body: {
number: 11,
title: "T",
state: "open",
user: { login: "alexion" },
draft: false,
merged: false,
comments: 0,
body: "b",
head: { sha: "sha11", ref: "f" },
},
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/11/reviews",
body: [
{
id: 51,
state: "COMMENT",
official: false,
stale: false,
dismissed: false,
user: { login: "grace" },
body: "",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/pulls/11/reviews/51/comments",
body: [
{
id: 1010,
user: { login: "grace" },
path: "c.ts",
diff_hunk: "@@ -2,3 +2,3 @@\n keep1\n drop_me\n+keep2",
body: "boundary",
},
],
},
{
method: "GET",
path: "/api/v1/repos/testowner/testrepo/commits/sha11/status",
body: { sha: "sha11", total_count: 0, statuses: [] },
},
]);
const { stdout, exitCode } = await runCliTest(["pr", "view", "11", "--reviews"], {
env: testModeEnv(server.url),
});
expect(exitCode).toBe(0);
expect(stdout).toContain('1010,grace,c.ts,no,"@@ -2,3 +2,3 @@\\n drop_me\\n+keep2",boundary');
});
it("reports a nonexistent PR as PR_NOT_FOUND with exit 1", async () => {
server = await startFixtureServer([
{

View File

@@ -402,3 +402,149 @@ describe("search empty results", () => {
});
}
});
/**
* The `help[1]:` next-step line is emitted by `suggestCommand`, so it appears
* wrapped in a `Run \`gitea-axi …\`` line with `-R`/`--login` normalization.
* These tests pull that help block out of the output and assert on the
* count-conditional suggestion within it.
*/
function helpBlock(stdout: string): string {
const lines = stdout.split("\n");
const start = lines.findIndex((line) => /^help\[\d+\]:/.test(line));
expect(start).toBeGreaterThanOrEqual(0);
// The block runs from the help[N]: header to the next blank line / EOF.
const rest = lines.slice(start);
const end = rest.findIndex((line, i) => i > 0 && line.trim() === "");
return (end === -1 ? rest : rest.slice(0, end)).join("\n");
}
describe("search issues count-conditional next-step suggestion", () => {
it("suggests the list fallback when there are zero in-repo matches", async () => {
server = await startFixtureServer([
{ method: "GET", path: SEARCH_PATH, headers: { "X-Total-Count": "0" }, body: [] },
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue list --state all");
expect(help).toContain("to list all issues instead");
expect(help).not.toContain("issue view");
});
it("fills the real number when there is exactly one in-repo match", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "1" },
body: [searchIssueOf(2, { title: "Fix login redirect loop" })],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue view 2");
expect(help).toContain("to see it in full");
expect(help).not.toContain("issue view <number>");
});
it("keeps the <number> placeholder when there are two or more in-repo matches", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "2" },
body: [
searchIssueOf(42, { title: "Fix login redirect loop" }),
searchIssueOf(41, { title: "Login button unresponsive" }),
],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue view <number>");
expect(help).toContain("to see a match in full");
});
});
describe("search prs count-conditional next-step suggestion", () => {
it("suggests the list fallback when there are zero in-repo matches", async () => {
server = await startFixtureServer([
{ method: "GET", path: SEARCH_PATH, headers: { "X-Total-Count": "0" }, body: [] },
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr list --state all");
expect(help).toContain("to list all pull requests instead");
expect(help).not.toContain("pr view");
});
it("fills the real number when there is exactly one in-repo match", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "1" },
body: [searchIssueOf(2, { title: "Retry flaky CI jobs" })],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr view 2");
expect(help).toContain("to see it in full");
expect(help).not.toContain("pr view <number>");
});
it("keeps the <number> placeholder when there are two or more in-repo matches", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "2" },
body: [
searchIssueOf(73, { title: "Retry flaky CI jobs" }),
searchIssueOf(72, { title: "Stabilize CI runners" }),
],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr view <number>");
expect(help).toContain("to see a match in full");
});
});

View File

@@ -1,17 +1,147 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { delimiter, isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { runCliTest } from "./harness.js";
import { type CliResult, runCliTest } from "./harness.js";
let tempHome: string;
/** Restore write permission everywhere under `dir` so the tree can be removed. */
function restorePermissions(dir: string): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
chmodSync(path, entry.isDirectory() ? 0o700 : 0o600);
if (entry.isDirectory()) {
restorePermissions(path);
}
}
}
afterEach(() => {
if (tempHome) {
// Not every test creates a HOME, so this may be a directory an earlier test
// already removed.
if (tempHome && existsSync(tempHome)) {
// The read-only-target tests leave files and directories unwritable, and
// an unwritable directory cannot have its entries unlinked.
chmodSync(tempHome, 0o700);
restorePermissions(tempHome);
rmSync(tempHome, { recursive: true, force: true });
}
tempHome = "";
});
/**
* Permission bits are not enforced for root, so the read-only-target tests
* cannot express their premise there and are skipped rather than passing
* vacuously.
*/
const itUnlessRoot = process.getuid?.() === 0 ? it.skip : it;
/**
* Assert the error's wording infers no particular configuration manager.
*
* Read-only is not diagnostic of one, so naming one would be wrong for most
* readers who hit this. The paths the error quotes are exempt — they are the
* user's own, and here the temp directory sits under a `nix-shell` TMPDIR.
*/
function expectNamesNoManager(stdout: string, home: string): void {
const wording = stdout.split(home).join("<home>");
expect(wording).not.toMatch(/\b(nix|home-manager|nixos|chezmoi|ansible|stow|guix)\b/i);
}
/**
* Run `body` with `process.env.PATH` replaced. `setup hooks` reads PATH from the
* process rather than the injected environment, because it has to agree with
* the agent SDK's own probing — so this is the seam that decides whether the
* recorded hook command is the bare name or the absolute entrypoint path.
*/
async function withPath(path: string, body: () => Promise<CliResult>): Promise<CliResult> {
const original = process.env.PATH;
process.env.PATH = path;
try {
return await body();
} finally {
process.env.PATH = original;
}
}
/**
* The entrypoint `setup hooks` resolves for itself — `src/main.js` here, since
* the dist layout mirrors src/ and setup.ts locates it relative to its own
* module.
*/
function entrypointPath(): string {
return fileURLToPath(new URL("../src/main.js", import.meta.url));
}
/** Write an executable `gitea-axi` into a fresh `dir`, and return that dir. */
function writeFakeBinary(dir: string, contents: string): string {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "gitea-axi"), contents, { mode: 0o755 });
return dir;
}
/**
* The SessionStart entry declared in `session-start-hook.json`, the committed
* specification the Nix home-manager module writes into a declarative
* configuration.
*
* That module and this command are two ways to arrive at the same entry, with
* nothing structural keeping them agreed — so the specification is read here
* rather than restated, and the assertion below is what holds them together. It
* fails if either side drifts, including if the agent SDK changes the envelope
* it writes out from under the imperative path.
*/
function declaredSessionStartEntry(): unknown {
return JSON.parse(
readFileSync(new URL("../session-start-hook.json", import.meta.url), "utf8"),
);
}
/**
* Run `setup hooks` with a wrapper-based install of this entrypoint on PATH.
*
* That is a wrapper-based install in miniature — a script that *invokes* the
* entrypoint, so its realpath is itself and the agent SDK could never match it
* from the entrypoint's side — and it is the shape every real install produces,
* Nix's included. It is also the only arrangement in which the bare name gets
* recorded, so any assertion about that name has to arrange it first.
*/
async function installHooksBehindWrapper(home: string): Promise<void> {
const binDir = writeFakeBinary(
join(home, "wrapper"),
`#!/bin/sh\nexec node ${entrypointPath()} "$@"\n`,
);
const { exitCode } = await withPath(`${binDir}${delimiter}${process.env.PATH ?? ""}`, () =>
runCliTest(["setup", "hooks"], { env: { HOME: home } }),
);
expect(exitCode).toBe(0);
}
/** The Claude Code settings the hook install wrote into `home`. */
function claudeSettings(home: string) {
return JSON.parse(readFileSync(join(home, ".claude", "settings.json"), "utf8"));
}
/** The single command string recorded in the Claude Code SessionStart hook. */
function recordedHookCommand(home: string): string {
const settings = claudeSettings(home);
expect(settings.hooks.SessionStart).toHaveLength(1);
expect(settings.hooks.SessionStart[0].hooks).toHaveLength(1);
return settings.hooks.SessionStart[0].hooks[0].command;
}
describe("setup", () => {
it("installs the skill and is idempotent: installed -> unchanged -> updated", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
@@ -37,6 +167,60 @@ describe("setup", () => {
expect(third.stdout).toContain("status: updated");
expect(readFileSync(installedPath, "utf8")).not.toBe("tampered");
});
itUnlessRoot("reports a read-only skill target as a structured error", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
// A declaratively managed install in miniature: the file is present, its
// content differs from the bundled copy, and it cannot be written.
mkdirSync(join(tempHome, ".claude", "skills", "gitea-axi"), { recursive: true });
writeFileSync(installedPath, "managed elsewhere\n");
chmodSync(installedPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(installedPath);
expect(stdout).toContain("managed by another tool");
expectNamesNoManager(stdout, tempHome);
// The bundled copy is untouched by a failed run.
expect(readFileSync(installedPath, "utf8")).toBe("managed elsewhere\n");
});
itUnlessRoot("names the directory when it is the directory that is read-only", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const skillsDir = join(tempHome, ".claude", "skills");
// Nothing installed yet, and no new entry can be created here — so the
// blocked path is the directory, not the file that would have gone in it.
mkdirSync(skillsDir, { recursive: true });
chmodSync(skillsDir, 0o555);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(join(skillsDir, "gitea-axi"));
expectNamesNoManager(stdout, tempHome);
});
itUnlessRoot("succeeds on a read-only skill target that is already up to date", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
const first = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(first.exitCode).toBe(0);
// Same bytes the command would write, so there is nothing to write and the
// target's being read-only is beside the point.
chmodSync(installedPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(0);
expect(stdout).toContain("status: unchanged");
});
});
describe("setup hooks", () => {
@@ -81,10 +265,77 @@ describe("setup hooks", () => {
const second = await runCliTest(["setup", "hooks"], { env: { HOME: tempHome } });
expect(second.exitCode).toBe(0);
const claudeSettingsPath = join(tempHome, ".claude", "settings.json");
const claudeSettings = JSON.parse(readFileSync(claudeSettingsPath, "utf8"));
expect(claudeSettings.hooks.SessionStart).toHaveLength(1);
expect(claudeSettings.hooks.SessionStart[0].hooks).toHaveLength(1);
const settings = claudeSettings(tempHome);
expect(settings.hooks.SessionStart).toHaveLength(1);
expect(settings.hooks.SessionStart[0].hooks).toHaveLength(1);
});
it("records the bare binary name when a wrapper on PATH runs this entrypoint", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
await installHooksBehindWrapper(tempHome);
expect(recordedHookCommand(tempHome)).toBe("gitea-axi");
});
it("writes exactly the SessionStart entry the packaged specification declares", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
await installHooksBehindWrapper(tempHome);
expect(claudeSettings(tempHome).hooks.SessionStart).toEqual([declaredSessionStartEntry()]);
});
it("falls back to the absolute entrypoint path when gitea-axi is not on PATH", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const emptyDir = join(tempHome, "empty");
mkdirSync(emptyDir, { recursive: true });
const { exitCode } = await withPath(emptyDir, () =>
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
);
expect(exitCode).toBe(0);
const command = recordedHookCommand(tempHome);
expect(isAbsolute(command)).toBe(true);
expect(command).toBe(entrypointPath());
});
it("falls back rather than recording a name for some unrelated gitea-axi", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
// Same name on PATH, different program. Recording the bare name here would
// point the hook at a binary the user never asked to install.
const binDir = writeFakeBinary(
join(tempHome, "impostor"),
"#!/bin/sh\nexec node /somewhere/else/dist/main.js \"$@\"\n",
);
const { exitCode } = await withPath(binDir, () =>
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
);
expect(exitCode).toBe(0);
expect(recordedHookCommand(tempHome)).toBe(entrypointPath());
});
itUnlessRoot("reports a read-only hook target as the same structured error", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const settingsPath = join(tempHome, ".claude", "settings.json");
mkdirSync(join(tempHome, ".claude"), { recursive: true });
writeFileSync(settingsPath, "{}\n");
chmodSync(settingsPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup", "hooks"], {
env: { HOME: tempHome },
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(settingsPath);
expect(stdout).toContain("managed by another tool");
expectNamesNoManager(stdout, tempHome);
});
});

View File

@@ -36,19 +36,23 @@ describe("bundled Agent Skill markdown", () => {
it("references each command group as a one-liner", () => {
const body = skill.toLowerCase();
for (const group of ["issue", "pr", "label", "search", "setup"]) {
for (const group of ["issue", "pr", "label", "search"]) {
expect(body, `expected the skill to mention the ${group} command group`).toContain(
group,
);
}
});
it("points at the bare dashboard and per-command help for discovery", () => {
it("steers find-then-act and does not push exploratory discovery", () => {
const body = skill.toLowerCase();
// Bare dashboard: running the binary with no arguments.
expect(body).toContain("no argument");
expect(body).toContain("dashboard");
// Per-command help.
expect(body).toContain("--help");
// The intended steering: find the target, then act on it.
expect(body).toContain("find the target");
expect(body).toContain("act on it");
// The find/act discipline: not a full survey, and not both find commands at once.
expect(body).toContain("not a survey");
expect(body).toContain("not both");
// The removed exploratory anti-pattern must be gone.
expect(body).not.toContain("dashboard");
expect(body).not.toContain("no argument");
});
});

View File

@@ -2,17 +2,20 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// The packaging tier: pack the real tarball, install it globally into a
// throwaway prefix, then drive the installed binary. It builds, packs, and
// fetches runtime deps from the registry, so it is far slower than the fast
// tiers and runs on its own via `test:pack`. Distribution touches no Gitea
// API, so this smoke test is the distribution analogue of the live-Gitea
// e2e tier rather than a member of it.
// The packaging tier, in two facets. `tarball` asserts the shape of the
// packed npm tarball and its manifest; `installed-binary` drives an
// installed gitea-axi, either one named by GITEA_AXI_INSTALLED_BIN or one
// it packs and installs itself. It builds, packs, and fetches runtime deps
// from the registry, so it is far slower than the fast tiers and runs on
// its own via `test:pack`. Distribution touches no Gitea API, so this smoke
// test is the distribution analogue of the live-Gitea e2e tier rather than
// a member of it.
include: ["test/packaging/**/*.test.ts"],
testTimeout: 180_000,
hookTimeout: 300_000,
// Keep the pack/install work in one process: the tarball is built once in a
// shared setup and reused across the assertions.
// Each facet's setup may run `npm pack` against the one shared project
// root, whose `prepack` writes `dist/`; serialize the files so two builds
// cannot race on it.
fileParallelism: false,
passWithNoTests: true,
},