Compare commits

...

77 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
0fdc2bed9e docs: rewrite bench README as reader-first with results
All checks were successful
CI / test (pull_request) Successful in 53s
CI / test (push) Successful in 51s
Reframe bench/README.md from maintainer-facing internals to a reader-facing
overview: what the benchmark measures, how it works at a high level, and what
it found. Add the completed 4-arm results (240 samples, 2026-07-17).

Remove the per-file layout, the vocabulary glossary, the packaging note, the
spec/ADR pointer, and the run/report/test command sections — that detail lives
in the code and the spec, and it buried the point. The result is a one-screen
doc: pitch and headline finding, a one-paragraph methodology, and the results
table sorted by cost-equivalent tokens.
2026-07-17 08:47:06 -04:00
de75ac9479 fix: make bench arm bin provisioning idempotent across trials (#34)
All checks were successful
CI / test (push) Successful in 56s
2026-07-16 22:14:08 -04:00
1c92a389dd fix: capture per-model token usage in bench SDK driver
All checks were successful
CI / test (push) Successful in 52s
sumTokens read the Agent SDK's per-model `modelUsage` entries with
snake_case field names, but the SDK reports those per-model entries in
camelCase (`inputTokens`, `cacheReadInputTokens`, ...). Every token
component therefore fell through to zero, silently zeroing the
cost-equivalent-token headline metric — while `total_cost_usd` and
`num_turns` (top-level snake_case) kept working and masked it.

Read `modelUsage` with the correct camelCase fields, keeping the
snake_case aggregate `usage` as the fallback. Export `sumTokens` and add
a regression test covering both the per-model camelCase sum (folding in
the auxiliary model) and the snake_case fallback, so a future SDK
field-casing drift fails a test instead of producing zero-token samples.
2026-07-16 22:01:14 -04:00
53342f67e3 feat: add benchmark reporting command (task 0031)
All checks were successful
CI / test (pull_request) Successful in 52s
CI / test (push) Successful in 52s
Add the maintainer-facing `bench:report` command, the offline counterpart
to `bench:run`: it opens the sample store, drains it, aggregates against the
scored suite and bonus definitions, and prints the aggregator's comparison to
stdout. It renders whatever has accumulated, annotating incomplete coverage
rather than blocking on a complete matrix.

`parseReportArgs` is the pure argument seam (`--store`, `--help`, plus a
`--self-review` / `--no-self-review` variant selector). Unlike `bench:run` the
boundary is offline — it reads only the local store, no host or Agent SDK — so
the whole `runReportCommand` is deterministic and unit-tested, not smoke-run.

Move `DEFAULT_STORE_ROOT` to `store.ts` as the single source of truth,
re-exported from `run.ts` for its existing importers.
2026-07-16 16:30:20 -04:00
7216d3cc31 feat: add benchmark aggregator and reporting (task 0030)
All checks were successful
CI / test (pull_request) Successful in 51s
CI / test (push) Successful in 50s
Add bench/aggregate.ts: the pure aggregator seam that rolls the
accumulated sample store into a readable comparison. aggregate produces
a headline table (one row per arm, cost-equivalent tokens as the
headline plus raw tokens, turns, duration, success rate, coverage, and
imputed cost as a de-emphasized secondary column), per-tier and
per-token-component breakdowns, and the separate bonus table; renderReport
renders it as stable text. Cost-equivalent tokens are weighted at render
time from the four retained components per ADR 0014, and incomplete
coverage is annotated rather than hidden. Unit-tested against synthetic
append-only sample stores.
2026-07-16 12:46:03 -04:00
80a4fafa06 feat: add benchmark run-loop command (task 0029)
All checks were successful
CI / test (pull_request) Successful in 51s
CI / test (push) Successful in 52s
Add the maintainer-facing command that runs one chosen benchmark cell on
demand, so only the token budget available at that moment is spent.

runCells (bench/run-loop.ts) runs one (arm, task) cell for a batch of
trials — defaulting to five with a reporting floor of three — by driving
the existing single-cell runner and the append-only sample store rather
than reimplementing orchestration. Re-running a cell deepens it: trial
numbering continues past the highest trial the cell already holds and the
new samples append, so a cell's sample size grows across sittings without
overwriting prior runs.

bench/run.ts is the command: parseRunArgs is the pure, unit-tested
argument seam, and runBenchCommand is the live boundary that resolves host
access, resolves the scored suite against the host's self-review support,
selects the task, and drives the run loop. It is invoked via the new
bench:run npm script, run under tsx (a new devDependency) because the
harness's .js-specifier imports need a TypeScript-aware runner. The Claude
Agent SDK is now declared as an optional peerDependency — documented but
neither installed for package consumers nor pulled into CI.

Every arm runs on the driver's single fixed model; the command exposes no
per-cell model override that could break cross-arm comparability. The
default store root bench/results/ is gitignored.
2026-07-16 10:44:52 -04:00
e8310fb616 feat: add benchmark task suite (task 0028)
All checks were successful
CI / test (pull_request) Successful in 51s
CI / test (push) Successful in 52s
Add the full 20-task scored suite and the capability-asymmetric bonus
definitions, plus the self-review capability probe that resolves the two
review tasks.

buildScoredSuite returns the shared-surface tasks weighted four read /
six single-mutation / six find-then-act / four multi-step, each a
natural-language intent parametrized against the seed and carrying a tier
and a scoring spec keyed on the single user. The two find-then-act review
tasks are approve/request-changes when the host permits self-review and
comment reviews otherwise; buildBonusTasks emits the approve/request-changes
operations as bonus entries in the fallback case, alongside the static
both-direction bonus definitions (gitea-axi's search/diff/checks/checkout/
issue-dependency edges, and the not-applicable repository/release/milestone
operations).

self-review.ts adds probeSelfReview and detectSelfReviewSupport, the live
boundary that determines self-review support once per sweep; it reuses the
now-exported non-throwing request helper from seed.ts.
2026-07-16 09:51:22 -04:00
c6a972734e feat: add benchmark single-cell runner (task 0027)
All checks were successful
CI / test (pull_request) Successful in 50s
CI / test (push) Successful in 52s
Thread every benchmark layer to run one (arm, task, trial) cell end to
end: provision and seed a throwaway repository, run the agent under the
active arm bounded by a turn cap and a wall-clock backstop, audit the
transcript, capture and score the post-run state, append the sample, and
delete the repository.

- runner.ts: runCell orchestration behind the BenchHost and AgentDriver
  seams, so the flow is unit-tested with fakes while the live wiring is
  validated by a smoke run; turn-cap and wall-clock failures are tagged
  confused-versus-hung, and a leaked transcript is flagged invalid.
- audit.ts: the post-run transcript audit plus the shared
  foreignToolReason predicate both isolation enforcement points consume.
- task.ts: the runnable BenchTask wrapper and one sample single-mutation
  task exercising the full path.
- snapshot.ts: captureRepoState, the seed's read-back counterpart, in the
  RepoState shape the checker diffs against.
- host.ts / sdk-driver.ts: the live BenchHost and the Claude Agent SDK
  driver (an optional peer, loaded via dynamic import) for real runs.
- runner.smoke.test.ts: the live tracer-bullet tier, skipping cleanly
  when no host or SDK is configured.
2026-07-16 09:17:38 -04:00
9a2ba40657 feat: add benchmark arm scaffolding (task 0026)
All checks were successful
CI / test (pull_request) Successful in 50s
CI / test (push) Successful in 52s
Add bench/arm.ts, the per-arm scaffolding that produces the single arm
definition the runner consumes. Every arm shares an identical task-agnostic
base prompt and the same repository coordinates and token; each arm then
receives a minimal, symmetric bootstrap.

The deliberate asymmetries follow the shipped products: the gitea-axi arm
embeds the bundled Agent Skill (its body, charging its ambient cost to
gitea-axi); the tea and raw-api arms get a one-line native-discovery pointer;
the gitea-mcp arm runs with the shell disabled and only the MCP server
attached, its dispatcher schemas loading eagerly. Shell arms' PATH and guard
come from bench/guard.ts.
2026-07-16 08:39:21 -04:00
8666c53557 feat: add benchmark seed provisioning (task 0025)
All checks were successful
CI / test (pull_request) Successful in 52s
CI / test (push) Successful in 49s
Add the deterministic, idempotent seed that brings a freshly provisioned
throwaway repository to a known ground truth before a trial runs, scripted
over the live Gitea API.

- bench/seed-plan.ts: the pure ground truth (fixed labels, an issue spread
  across the discriminating dimensions, and labelled/reviewed/real-branch
  pull requests) plus groundTruth(user), realizing it into a RepoState.
- bench/seed.ts: idempotent seeding reconciled by natural key, reusing
  gitea-axi's own tea-login credential discovery (no new secret handling).
- A live smoke tier (test:bench:smoke) validating the seed end-to-end and
  skipping cleanly when no host is configured, kept out of the deterministic
  bench tier.

Export selectLogin from src/context.ts so the bench reuses the exact
credential-selection path.
2026-07-16 08:07:29 -04:00
66f576a2b7 feat: add benchmark checker and scoring spec (task 0024)
All checks were successful
CI / test (pull_request) Successful in 51s
CI / test (push) Successful in 48s
2026-07-16 07:33:31 -04:00
0436dc25fd feat: add benchmark tool-isolation guard (task 0023)
All checks were successful
CI / test (pull_request) Successful in 50s
CI / test (push) Successful in 49s
Add the guard that confines each benchmark arm's agent to exactly one
tool, so a result measures the tool rather than the agent's choice
between tools.

`guardCommand` inspects every binary a proposed shell command would
reach — across pipelines, sequences, subshells, command and process
substitutions, redirections, and leading environment assignments — and
permits only the active arm's one allow-listed binary plus a curated set
of harmless read-only utilities. Foreign binaries, absolute-path
evasions (even of the arm's own binary), and interpreter-based fetch
tricks are denied; the gitea-mcp arm runs with the shell disabled
entirely. `provisionArmBin` produces a curated per-arm bin directory
exposing only that arm's binary as the convenience layer behind the
authoritative guard.

Tests are colocated in bench/guard.test.ts and run via `npm run
test:bench`.
2026-07-15 22:26:55 -04:00
9bf8c85dc3 feat: add benchmark scaffold and result store (task 0022)
All checks were successful
CI / test (pull_request) Successful in 50s
CI / test (push) Successful in 50s
Lay the foundation the benchmark harness reads and writes: a bench/
directory (excluded from the published npm package), the immutable
result-record shape, and an append-only per-cell sample store.

- bench/result.ts: the ResultRecord shape — four token components,
  turns, duration, imputed cost, tagged pass/fail outcome, and the
  arm/task/tier/trial/timestamp tags. Arm and Tier are typed unions.
- bench/store.ts: append-only sample store, one JSONL file per cell at
  <root>/<arm>/<taskId>.jsonl; deepening a cell only ever adds samples.
- bench/README.md: harness working docs and benchmark vocabulary, kept
  out of the tool's domain glossary per the spec.
- Dedicated bench test tier (vitest.bench.config.ts, npm run test:bench)
  kept out of the fast tier; tsconfig typechecks bench.
- Packaging tier asserts bench/ never ships in the tarball.
2026-07-15 10:20:15 -04:00
d445b2bd2b docs: add benchmark harness spec, ADRs, and task breakdown
All checks were successful
CI / test (pull_request) Successful in 50s
CI / test (push) Successful in 49s
Add the benchmark-harness spec, three supporting ADRs (cost-equivalent
token metric, single-user seed, guard-based tool isolation), and the
0022-0030 task breakdown that slices the harness into foundational
seams, an integrating single-cell runner, and the reporting layer.
2026-07-15 10:04:28 -04:00
b5955cd7b8 feat: truncate --fields body uniformly (task 0021)
All checks were successful
CI / test (pull_request) Successful in 49s
CI / test (push) Successful in 49s
Rule that the `body` extra field truncates at 500 chars like `issue view`,
resolving the spec's Principle 3 / Command Surface contradiction. Route the
`body` extractor through a new `truncatedBody()` FieldDef so `issue list`,
`issue create`, `pr list`, and `search` all present it exactly as the detail
views do, and add a `--full` flag to each to suppress truncation, keeping the
inline hint's "use --full" promise honest.
2026-07-14 12:23:24 -04:00
ed87f023cb feat: add npm publish readiness (task 0020)
All checks were successful
CI / test (pull_request) Successful in 52s
CI / test (push) Successful in 52s
Complete the distribution metadata and publish flow for the unscoped
`gitea-axi` package:

- add `repository`, `homepage`, and `bugs` to package.json
- add `publishConfig` (public access, npmjs registry) so `npm publish`
  needs no extra flags
- replace `prepublishOnly` with `prepack: npm run build`, so both
  `npm pack` and `npm publish` rebuild `dist/` first
- document the single-command flow in PUBLISHING.md
- add a packaging smoke-test tier (`test:pack`) that packs the real
  tarball, installs it globally, and drives the installed binary
  (`--help`, dashboard header, and `setup` finding the bundled skill)
2026-07-14 12:00:37 -04:00
be7226b321 feat: add setup skill/hooks and update shadow (task 0018)
All checks were successful
CI / test (pull_request) Successful in 52s
CI / test (push) Successful in 51s
Distribute gitea-axi's ambient context via explicit user actions (ADRs
0009, 0013), with no postinstall script:

- Bundle the Agent Skill markdown at skills/gitea-axi/SKILL.md (a
  minimal pointer, not a command reference) and ship it via package.json
  files.
- Add `setup`, which installs the skill into ~/.claude/skills/
  idempotently (installed/updated/unchanged).
- Add `setup hooks`, which registers a SessionStart hook running the
  bare dashboard for Claude Code, Codex, and OpenCode via the SDK's
  installSessionStartHooks(), updating managed entries in place.
- Shadow the SDK's built-in `update` so it fails with VALIDATION_ERROR
  and points at the npm update command, keeping the ten-code error list
  intact.

Integration tests drive all three at the CLI seam against a temporary
HOME; these commands make no Gitea API calls, so there is no live-Gitea
e2e case.
2026-07-14 11:26:56 -04:00
5b6a9b4917 feat: add the two-tier dashboard (task 0017)
All checks were successful
CI / test (pull_request) Successful in 47s
CI / test (push) Successful in 50s
Wire the bare `gitea-axi` home command to a two-tier repository
dashboard (ADR 0012): the short tier shows up to 3 open issues and 3
open PRs with the client-side `review` decision, and `--full` shows the
20-row open-PR table plus open issue counts grouped by label, aggregated
across every page of open issues up to the 1000-issue cap with a `+`
suffix when capped. Empty states render the raw `prs: 0 open` /
`issues: 0 open` strings; issue fetches pass `type=issues`; outside a
Gitea repo the dashboard errors with REPO_NOT_FOUND.

`paginate.ts` now reports whether pagination stopped at the cap, which
drives the label-count `+` suffix.
2026-07-14 11:01:07 -04:00
c2c5f1728c test: poll for indexer consistency in search e2e (task 0016)
All checks were successful
CI / test (pull_request) Successful in 45s
CI / test (push) Successful in 46s
Gitea's issue/PR search endpoint is backed by an asynchronous, eventually-
consistent indexer (bleve by default), so a PR opened moments earlier in the
e2e `beforeAll` was not yet searchable when `search prs` ran, and the live
assertion saw zero matches. Both live-search tests now poll the search via
`expect.poll` until the freshly-created content is indexed before asserting on
the exact locator-schema output. Record the eventual-consistency behaviour as
a project gotcha.
2026-07-14 10:09:51 -04:00
4c3dde26b4 feat: add search commands (task 0016)
Some checks failed
CI / test (pull_request) Failing after 45s
Add `search issues <query>` and `search prs <query>`, the full-text escape
hatch the forbidden `--search` flag on the list commands redirects to.

Both hit Gitea's cross-repo issue-search endpoint with the query, a `type`
of issues or pulls, and the owner param, then filter results to the current
repository client-side via each result's `repository` field — the endpoint
has no repo-name filter. The count line reports `count: N of T total` with
`T` from the filtered set (ADR 0005), never the endpoint's cross-repo
`X-Total-Count`.

The positional query is required (VALIDATION_ERROR if missing). Flags:
`--state` (default open), `--label` (comma-separated names passed straight
through as the API `labels` param), `--limit` (default 30), and `--fields`.
Default output is the locator schema (`number`, `title`, `state`, `author`,
`created`) under `issues:` / `pull_requests:` blocks matching the list
commands — search finds the number, `issue view` / `pr view` load the detail.

Covered by fixture-server tests for both types, cross-repo filtering, the
count rule, each flag, the empty state, and missing-query validation, plus
end-to-end tests against a live Gitea instance.
2026-07-14 09:55:20 -04:00
6661239afe docs: record e2e test requirements across remaining tasks
All checks were successful
CI / test (push) Successful in 44s
2026-07-14 09:33:01 -04:00
03937a8f6e test: add label e2e coverage and fixture-tier backfill (task 0015)
Some checks failed
CI / test (pull_request) Successful in 50s
CI / test (push) Has been cancelled
2026-07-14 07:35:45 -04:00
0bf914cbdd feat: add label commands (task 0015)
Some checks failed
CI / test (pull_request) Failing after 38s
2026-07-14 07:24:16 -04:00
d034fce3ea Merge pull request 'feat: add pr diff and checkout (task 0014)' (#14) from task-0014-pr-diff-and-checkout into main
All checks were successful
CI / test (push) Successful in 42s
2026-07-14 07:02:40 -04:00
3db6d421d4 feat: add pr diff and checkout (task 0014)
All checks were successful
CI / test (pull_request) Successful in 42s
Add the two PR commands that touch content and the local worktree:

- `pr diff <n>` fetches the raw diff from the `.diff` endpoint (forcing a
  text response, which the JSON-defaulting client would otherwise discard),
  truncates at 4000 chars with separate `truncated`/`original_length` fields
  and a prepended `--full` suggestion; `--full` returns the raw diff.
- `pr checkout <n>` reads the PR head branch from the PR fetch and fetches
  `refs/pull/{n}/head` from origin (uniform for same-repo and fork PRs, ADR
  0011), three-cased on local branch state so re-checkout is idempotent and
  divergent local commits fail with `GIT_ERROR` rather than being discarded.

Introduces the `GIT_ERROR` mapping (`runGit`) carrying git's first stderr
line plus a remediation help line, and widens the PR 404 classifier so a
`.diff` path still resolves to `PR_NOT_FOUND`.
2026-07-13 22:15:03 -04:00
ba5171bb59 Merge pull request 'feat: add pr review (task 0013)' (#13) from task-0013-pr-review into main
All checks were successful
CI / test (push) Successful in 38s
2026-07-13 21:52:28 -04:00
7d72f94746 feat: add pr review (task 0013)
All checks were successful
CI / test (pull_request) Successful in 38s
Add `pr review <n>` with the three action switches --approve,
--request-changes, and --comment, plus --body/--body-file. Exactly one
action flag is required; zero or multiple raise VALIDATION_ERROR before
any API call, mirroring the merge shorthand-conflict rule. Body
requirements are left to Gitea: a body-less event it rejects surfaces its
422 as VALIDATION_ERROR carrying the server's message. Output is
`review: { number, action }`.
2026-07-13 20:44:59 -04:00
40859b9e6b feat: add pr merge and update-branch (task 0012)
All checks were successful
CI / test (pull_request) Successful in 41s
CI / test (push) Successful in 40s
Add `pr merge` with all six Gitea methods, the three common-method
shorthands, `--auto`, `--delete-branch`, `--subject`, `--body`/`--body-file`,
and `--merge-commit-id` (required with and only valid for
`--method manually-merged`). Conflicting method flags and the
manually-merged/commit-id pairing are rejected as VALIDATION_ERROR before
any API call. An already-merged PR short-circuits to a `pull_request` entity
block with `merged_by`/`merged_at`; merge-blocked 405/409 responses surface
as VALIDATION_ERROR with update-branch/checkout remediation.

Add `pr update-branch` merging the base into the head via the update
endpoint with `--style <merge|rebase>` (default merge).
2026-07-13 20:22:23 -04:00
965ece306f feat: add pr edit, close, and reopen (task 0011)
All checks were successful
CI / test (pull_request) Successful in 38s
CI / test (push) Successful in 39s
Add the PR-side state mutations mirroring the issue-side slice:

- `pr edit` applies title/body/base/milestone and the recomputed assignee
  list in one PATCH, with additive label endpoints and (per the ADR 0007
  amendment) the dedicated requested-reviewers POST/DELETE endpoints for
  `--add-reviewer`/`--remove-reviewer`.
- `pr close --comment` posts the comment after the PATCH and surfaces a
  comment-post failure; an already-closed or merged PR is an `already: true`
  no-op reporting the actual state.
- `pr reopen` is an `already: true` no-op when already open.

Extract the fetch-then-patch assignee merge into a shared `src/assignees.ts`
(`mergeAssignees` + `assigneeLogins`), now used by both `issue edit` and
`pr edit`.
2026-07-13 19:55:00 -04:00
6c15e6082f feat: add issue blocks and blocked-by (task 0007)
All checks were successful
CI / test (pull_request) Successful in 35s
CI / test (push) Successful in 35s
2026-07-13 19:41:04 -04:00
16d2f29f19 feat: add pr view and checks (task 0009)
All checks were successful
CI / test (pull_request) Successful in 36s
CI / test (push) Successful in 37s
Add `pr view <n>` and `pr checks <n>`, built on the truncation machinery
and review fetches from earlier slices.

`pr view` uses the three-call fetch pattern (PR + reviews in parallel, then
the head commit's combined status) so `checks`, `comment_count`, and
`review_count` are in the default output; `--comments`, `--reviews`, and
`--full` behave as on `issue view`, with `--reviews` exposing Gitea's
`official`/`stale` fields plus per-review inline comments.

`pr checks <n>` renders the checks summary line and the `{ name, conclusion }`
rows, or the scalar no-CI message when no statuses exist.

The state→conclusion mapping and summary live in a new `src/checks.ts`;
`commentRows` is extracted into `src/comment.ts` and shared with `issue view`.
2026-07-12 20:22:17 -04:00
6333058b72 feat: add pr list (task 0008)
All checks were successful
CI / test (pull_request) Successful in 30s
CI / test (push) Successful in 31s
Implements `pr list` with the two policies later PR slices reuse:
client-side filtering with the filtered-set count line (ADR 0005) and
the official-first reviewDecision via parallel per-PR review fetches
(ADR 0006), extracted into src/review.ts.

API-supported flags map to their params (--state, --author→poster,
--label name→ID, --label-id, --sort, --limit, --fields); --assignee,
--base, --head, and --draft filter in-process after full pagination,
with the count line's total taken from the filtered set. --search is
refused with a redirect to `search prs`.

Adds a boolText field extractor and a shared parsePositiveInt helper,
the latter also adopted by issue list's --limit parsing.
2026-07-12 19:23:29 -04:00
7a41807a8a feat: add issue delete, pin, and unpin (task 0006)
All checks were successful
CI / test (pull_request) Successful in 29s
CI / test (push) Successful in 32s
Add the remaining simple issue mutations. `issue delete` hard-deletes via
the DELETE endpoint and is deliberately not idempotent — a nonexistent
issue yields ISSUE_NOT_FOUND rather than reporting success. `issue pin`
and `issue unpin` read the current pin state (Gitea's pin_order field) and
short-circuit to an idempotent no-op with an Already pinned/unpinned
message when there is nothing to do.
2026-07-12 19:02:02 -04:00
f526acbb7b docs: correct pr create login to alexion in dogfood instructions
All checks were successful
CI / test (push) Successful in 28s
2026-07-12 18:55:59 -04:00
6296f47fab feat: add issue edit, close, and reopen (task 0005)
All checks were successful
CI / test (pull_request) Successful in 27s
CI / test (push) Successful in 37s
Implement the issue state-transition mutations:

- issue edit: --title, --body/--body-file, --add-label/--remove-label,
  --add-assignee/--remove-assignee, --milestone. Label mutations use
  Gitea's dedicated additive/removal endpoints (names added directly,
  removals resolved to IDs, unapplied-label 404 as silent success);
  assignees use fetch-then-patch (ADR 0007); title/body/milestone and the
  recomputed assignee list travel in one PATCH. Outputs edited: {number,
  status: ok}.
- issue close: PATCHes state closed, optional --comment posted after with
  its failure surfaced; already-closed short-circuits with Already closed.
- issue reopen: PATCHes state open; already-open short-circuits with
  Already open.

Extract a shared getIssue helper used by view/edit/close/reopen.
2026-07-12 09:47:51 -04:00
3c581b7c0e docs: prefer gitea-axi over tea for opening pull requests
All checks were successful
CI / test (push) Successful in 27s
2026-07-12 09:29:35 -04:00
348f28a54d feat: complete issue list filters, sort, and fields (task 0002)
Finish the `issue list` flag surface left minimal by the tracer slice:

- Server-side filters `--label`, `--assignee`, `--author`, `--milestone`,
  mapped to Gitea's `labels`, `assigned_by`, `created_by`, `milestones`.
- Client-side `--sort <created|updated|comments>`, always descending, over
  the fully paginated set (ADR 0005); `--limit` caps the sorted result.
- `--fields` exposing body, closedAt, labels, milestone, updatedAt, url.
- `--search` refused with a VALIDATION_ERROR redirecting to `search issues`.

Exhaustive pagination lands as a shared `paginate.ts`, since ADR 0005 makes
it a policy later slices reuse; `lookup.ts` drops its hand-rolled copy of the
same loop. The helper carries the 20-page cap that copy encoded, matching the
1000-item ceiling Principle 8 sets.
2026-07-12 09:29:35 -04:00
12a7baa378 docs: capture the --fields body truncation conflict as task 0021
All checks were successful
CI / test (push) Successful in 29s
2026-07-12 09:27:35 -04:00
590691fc96 feat: add pr create and comment (task 0010)
All checks were successful
CI / test (pull_request) Successful in 30s
CI / test (push) Successful in 29s
`pr create` takes --title (required), --body/--body-file, --base, --head,
--assignee, --reviewer, repeatable name-resolved --label, and --milestone.
An omitted --head defaults to the current local branch; an omitted --base to
the repository's default branch. Before creating, an existing open PR for the
same base/head pair short-circuits to `pull_request: { number, url, already:
true }` rather than opening a duplicate; only an open PR does, since a closed
one's branches are free to be proposed again.

`pr comment <n>` posts through the shared issue-comment endpoint and returns
the created comment as `comment: { number, author, created, body }` (ADR 0008),
reporting a 404 as PR_NOT_FOUND since the caller asked about a pull request.

That comment block is now built in one place (src/comment.ts) for both issue
and pr comment, as ADR 0008 requires them to stay identical.
2026-07-11 21:03:40 -04:00
f82414a933 feat: add issue create and comment (task 0004)
All checks were successful
CI / test (pull_request) Successful in 24s
CI / test (push) Successful in 28s
Introduce the first mutations, along with the shared machinery the later
issue and PR mutation slices reuse.

- `issue create` with --title/--body/--body-file/--assignee/--label/
  --milestone/--fields, emitting `issue: { number, title, state, url }`
- `issue comment <n>`, echoing the created comment from the POST response
  with the body cleaned and truncated at 800 chars
- body-source resolution (--body vs --body-file), label and milestone
  name->ID lookup, repeatable flags, and the `joined`/`selectExtraFields`
  field extractors

Label lookup pages until exhausted, since a repo with more labels than one
page would otherwise fail to resolve a valid name. The end-to-end tier seeds
a mixed-case label and milestone and passes both in a different case, so the
case-insensitive lookup is verified against live Gitea rather than only
against fixtures.
2026-07-11 20:39:01 -04:00
5e66b4d746 feat: add issue view with body cleaning and truncation (task 0003)
All checks were successful
CI / test (pull_request) Successful in 26s
CI / test (push) Successful in 23s
Add `issue view <n>` as the first detail command, introducing the
content-cleaning and truncation machinery (src/body.ts) that later issue
and PR slices reuse.

- Default output: number, title, state, author, created, body (truncated
  at 500), plus comment_count; --comments expands every comment (bodies
  truncated at 800); --full suppresses all truncation.
- cleanBody runs only when a body exceeds its limit: normalizes Gitea
  issue/PR URLs on the detected host to Issue#N/PR#N, strips image embeds
  and long URLs, and collapses quoted blocks.
- Type guard: a PR number fails with VALIDATION_ERROR and a `pr view <n>`
  hint, detected via the fetched object's pull_request field.
- renderDetail joins the detail entity, optional sub-blocks, and help.
2026-07-11 19:36:24 -04:00
193 changed files with 26786 additions and 242 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

@@ -15,5 +15,7 @@ Simpler, portable, consistent with the stated reference.
## Consequences
`issue view` and `pr view` both accept `--full` to return untruncated body.
`issue list`, `issue create`, `pr list`, and `search` also accept `--full`, since they offer `body` via `--fields` and that field truncates identically (task 0021).
The flag keeps the inline hint's "use --full" promise honest on those commands too.
`pr diff` truncates at 4000 chars (matching gh-axi's `DIFF_TRUNCATE_LIMIT`); when truncated, a next-step suggestion to rerun with `--full` is prepended.
The `full_content` field name and temp-file design from the spec draft are dropped entirely.

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 @@
# Cost-equivalent tokens as the benchmark's headline metric
The benchmark compares how much each arm costs to drive.
The maintainer runs on a Claude subscription with a fixed weekly token allowance, so the scarce resource is token consumption against that allowance, not dollars.
The question is how to reduce each run's four token components — fresh input, cache-creation, cache-read, and output — into a single headline number that reflects weekly-budget burn.
Research into Anthropic's documentation established that the exact unit and per-component weighting of the subscription weekly limit are not publicly documented.
The one anchoring signal is that overage past the included allowance is billed at standard API rates, which points toward cost-weighted accounting rather than a flat token count.
## Considered Options
**Raw summed tokens as headline** (rejected) — Summing all four components at 1× is transparent and assumption-free, but cache-read routinely dominates the total, and Anthropic's API prices cache-reads at roughly a tenth of fresh input.
A raw sum therefore overstates the burn of arms whose context is largely cached (notably the eager-schema MCP arm) by up to an order of magnitude, which would misrank the arms on the very axis the benchmark exists to measure.
**Imputed dollars as headline** (rejected) — The runtime already reports an imputed cost that folds in every component at the correct weights.
It is an accurate comparative number, but it is expressed in a unit the maintainer does not spend; on a subscription no dollars leave the account, and the mental model is weekly tokens.
**Cost-equivalent tokens as headline** (chosen) — Weight the four components by Anthropic's published API pricing ratios (fresh input 1×, cache-write 1.25× or 2× by TTL, cache-read 0.1×, output 5×) and express the result as a token count.
This is tokens — the maintainer's unit — weighted the way their budget most plausibly burns, and it is the same ranking as the imputed dollar figure.
## Consequences
- The headline is cost-equivalent tokens; the raw summed tokens and the full four-component breakdown are recorded alongside every run, so the data can be re-weighted without re-running if the subscription's real accounting is ever documented.
- Imputed dollars are retained as a de-emphasized secondary column, portable for readers who are on the API rather than a subscription.
- The weighting is an explicit, documented assumption grounded in the overage-pricing signal, not a measured fact; an optional later validation could pin the real weekly weighting empirically by burning a known token mix.
- The auxiliary small model invoked by the runtime is counted rather than suppressed, since it is real consumption against the same allowance.

View File

@@ -0,0 +1,22 @@
# Single-user seed and its constraints on the task surface
The benchmark seeds a throwaway repository to a known state before each trial and scores tasks against that ground truth.
Seeding realistic author and assignee variety would require several Gitea accounts.
The maintainer prefers to run the benchmark under their single existing account rather than provision additional accounts.
## Considered Options
**Provision throwaway collaborator accounts** (rejected) — Multiple accounts would restore the author and assignee dimensions and enable non-self pull-request approvals, but they add account lifecycle and credential handling that the maintainer explicitly declined for this benchmark.
**Keep multi-user tasks and let arms fail** (rejected) — Retaining tasks that need distinct authors or a non-self reviewer would make those tasks impossible under one account for every arm uniformly, producing no comparative signal while consuming budget.
**Single-user seed with a redesigned surface** (chosen) — All seed content is authored by the one account, and the discriminating dimensions become label, state, assignee presence (assigned-to-self versus unassigned), and title keyword instead of author.
Tasks that assumed author or assignee variety are recast onto these axes: reassignment becomes assign-to-self or unassign, and author-filtered bulk mutation becomes a filter on assignee presence.
## Consequences
- Author filtering leaves the scored suite; it carries little signal with one author anyway.
- Review tasks in the scored suite use comment-type reviews, which a user may leave on their own pull request.
Whether the host permits self-approval and self-request-changes is verified during implementation; if permitted, those two tasks are promoted from comment reviews to approve and request-changes, otherwise approve and request-changes move to the bonus table as two-account scenarios.
- Non-self approval, distinct-author filtering, and distinct-assignee tasks are out of scope for the scored suite and belong to the multi-account bonus scenarios.
- The seed stays small and fully deterministic, which keeps the full-state diff used for collateral-damage checking cheap to compute and reason about.

View File

@@ -0,0 +1,21 @@
# Guard-based tool isolation instead of containers
The benchmark's validity depends on each arm's agent reaching exactly one tool.
If the tea-arm agent could quietly call `curl` or `gitea-axi`, the comparison would be meaningless.
The benchmark machine has no container runtime available, so per-arm operating-system sandboxing is not an option.
## Considered Options
**Container per arm** (rejected) — A container image carrying only the arm's binary would give hard, kernel-level isolation, but it requires installing and depending on a container runtime the maintainer does not have and declined to add for this benchmark.
**Curated PATH alone** (rejected) — Prepending a directory that exposes only the allowed binary is convenient but leaky: an agent can invoke another tool by absolute path, or reach the API through a language interpreter's fetch, bypassing PATH entirely.
**Guard callback as the authority** (chosen) — A callback inspects every proposed shell command and permits only the one binary allow-listed for the active arm plus harmless utilities, denying foreign binaries, absolute-path evasions, and interpreter-based fetch attempts.
A curated per-arm PATH backs it as a convenience layer, and the gitea-mcp arm disables the shell tool entirely and attaches only the MCP tools, giving that arm no leakage surface at all.
## Consequences
- The guard, not the PATH, is authoritative; the PATH is defense in depth.
- A blocked attempt is left in the transcript and counts as realistic wasted effort, reflecting an agent fumbling with a tool that cannot do the job; blocked calls are never silently retried or discarded.
- Every command is logged, and a post-run audit asserts no foreign tool was reached; a detected leak flags the trial invalid rather than letting it be scored.
- Isolation strength rests on the completeness of the guard's deny rules, so the guard is one of the harness's primary unit-tested seams, covering absolute-path and interpreter-fetch evasions explicitly.

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

@@ -0,0 +1,141 @@
## Problem Statement
gitea-axi claims to be an agent-ergonomic, low-token interface to Gitea issues and pull requests, positioned against the `tea` CLI, the official `gitea-mcp` server, and raw Gitea REST calls.
That claim is currently unmeasured.
The maintainer wants evidence — a reproducible comparison of correctness and token consumption across those four ways of driving Gitea from a coding agent, presented as a table analogous to the published gh-axi benchmark.
Because the maintainer runs on a Claude subscription with a fixed weekly token allowance (not API credits), the resource that actually matters is token usage, not dollars.
And because a full sweep would be expensive to run in one sitting, the benchmark must be runnable incrementally — one arm-and-task cell at a time, whenever spare budget is available — accumulating results rather than requiring a single monolithic run.
## Solution
A benchmark harness, living in a `bench/` directory in this repository (excluded from the npm package), that drives a Claude agent against a suite of realistic Gitea tasks under four tool conditions and records how each performs.
For each **cell** — one `(arm, task, trial)` combination — the harness provisions a fresh throwaway repository on the live Gitea host, seeds it to a known state, runs the agent with access to exactly one arm's tool, scores the outcome deterministically against the seeded ground truth, and appends an immutable result record.
An aggregator renders the accumulated records into a headline table (one row per arm) plus supporting views, annotating any partially-run cells rather than blocking on a complete matrix.
The four arms are **gitea-axi**, **tea** (native structured commands only), **gitea-mcp** (eager schemas), and **raw Gitea REST API** (curl).
The headline metric is **cost-equivalent tokens** — token components weighted by Anthropic's published API pricing ratios — reported alongside the raw token sum and the full component breakdown.
## User Stories
1. As the maintainer, I want to run a single benchmark cell on demand by selecting an arm and a task, so that I can spend only the token budget I have available at that moment.
2. As the maintainer, I want each cell to run against a freshly provisioned and seeded throwaway repository, so that trials are isolated and correctness is scored against a known ground truth.
3. As the maintainer, I want the agent in each arm to have access to exactly one tool, so that the comparison measures the tool rather than the agent's choice between tools.
4. As the maintainer, I want the gitea-axi arm to carry the product's bundled Agent Skill and the other arms to receive only a minimal pointer to their tool's native discovery affordance, so that each tool's real ambient-context cost is charged honestly.
5. As the maintainer, I want mutation tasks scored by diffing the entire post-run repository state against the expected end state, so that both the intended change and any collateral damage are caught.
6. As the maintainer, I want read tasks scored by matching required facts in the agent's final report against the seeded ground truth, so that correctness is judged without an LLM-judge.
7. As the maintainer, I want the headline metric to be cost-equivalent tokens with the raw sum and per-component breakdown recorded alongside, so that I can gauge weekly-budget burn today and re-weight the data if Anthropic's accounting is ever documented.
8. As the maintainer, I want each run bounded by a turn cap and a wall-clock backstop, so that a confused or hung agent cannot drain my budget.
9. As the maintainer, I want results appended as immutable timestamped samples, so that I can deepen any cell's sample size opportunistically without overwriting prior runs.
10. As the maintainer, I want the aggregator to render whatever results exist and annotate incomplete coverage, so that a half-run matrix still produces a readable, non-misleading table.
11. As the maintainer, I want tasks confined to the capability surface shared by all four arms, so that success differences reflect ergonomics rather than scope.
12. As the maintainer, I want capability-asymmetric operations reported in a separate bonus table, so that each tool's distinctive edges and gaps are visible without contaminating the headline comparison.
13. As the maintainer, I want a per-tier and per-token-component view derived from the same records, so that I can see where an arm wins or loses and what drives its cost.
14. As the maintainer, I want the harness to reject or flag any run in which the agent reached a tool outside its arm, so that a leak invalidates the trial instead of silently corrupting the metrics.
## Implementation Decisions
### Arms
Four arms are compared:
- **gitea-axi** — the hero arm; its bundled Agent Skill is loaded into the agent's context, matching how the product ships.
- **tea** — restricted to its native structured subcommands.
The `tea api` escape hatch is excluded, because allowing it would collapse the tea arm into the raw-API arm.
- **gitea-mcp** — the official server (v1.3.0 at design time), with its full set of dispatcher tools loaded eagerly.
- **raw Gitea REST API** — the agent issues `curl` calls against `HOST/api/v1` with a bearer token.
### Environment
Every cell runs against the live Gitea host, but isolation comes from a per-trial throwaway repository the harness creates, seeds, runs against, and deletes.
The harness authenticates by reusing gitea-axi's existing credential discovery path rather than introducing new secret handling.
### Seed
The seed is scripted through the Gitea API and is deterministic and idempotent.
It establishes a fixed set of labels with fixed colors, a spread of open and closed issues varying by label, assignee state, title keyword, and pre-existing comments, and a handful of pull requests including one labeled, one carrying an existing review, and one backed by a real pushed feature branch.
All content is authored by the single available user.
Because only one Gitea account is available, the seed carries no author or assignee variety across users.
Discriminating dimensions are label, state, assignee presence (assigned-to-self versus unassigned), and title keyword.
### Task suite
The scored suite is 20 tasks drawn only from the capability surface shared by all four arms — issue and pull-request listing, viewing, creation, editing, closing and reopening, commenting and comment retrieval, label management and application, review comments, merge, and assignee changes.
Tasks are phrased as natural-language intents, not command invocations, and are parametrized against the seed.
The suite is weighted toward discovery and multi-step work, where tool ergonomics diverge: roughly four read tasks, six single-mutation tasks, six find-then-act tasks, and four multi-step workflows.
Reviews in the scored suite use comment-type reviews, which a single user can leave on their own pull request.
Whether the host permits a user to approve or request changes on their own pull request is verified during implementation; if permitted, the two review tasks are promoted from comment reviews to approve and request-changes.
Capability-asymmetric operations are excluded from the scored suite and reported in a separate bonus table.
These fall in both directions: operations where tea, gitea-mcp, or raw API fall short of gitea-axi (full-text search, diff, checks, checkout, issue dependencies), and operations outside gitea-axi's scope entirely (repository, release, and milestone management), for which gitea-axi is reported as not-applicable.
### Scaffolding
All arms share an identical task-agnostic base prompt and the same repository coordinates and token.
Each arm then receives a minimal, symmetric bootstrap naming its tool and pointing at that tool's own native discovery affordance — with the deliberate exception that the gitea-axi arm loads the bundled Agent Skill, because the Skill is part of the shipped product and its token cost should be charged to gitea-axi.
The tea and raw-API arms receive a one-line pointer; the gitea-mcp arm's schemas load eagerly as its ambient cost.
### Tool isolation
Enforcement is a guard callback that inspects every proposed shell command and permits only the one binary allow-listed for the active arm plus harmless utilities, denying foreign binaries, absolute-path evasions, and fetch-via-interpreter tricks.
A curated per-arm PATH backs the guard as a convenience layer.
The gitea-mcp arm disables the shell tool entirely and attaches only the MCP tools.
Blocked attempts are left in the transcript and count as realistic wasted effort; they are not silently retried.
### Runner and metrics
The runner is the Claude Agent SDK on the maintainer's subscription, using a single fixed model at temperature zero across all arms.
The auxiliary small model that the agent runtime invokes for internal chores is included in metrics rather than suppressed, because it is real consumption.
Each completed run records the four token components (fresh input, cache-creation, cache-read, output), the turn count, the wall-clock duration, the imputed cost, and the pass/fail outcome.
The headline metric, **cost-equivalent tokens**, weights the components by Anthropic's published API pricing ratios (see the cost-equivalent-token-metric ADR).
The raw token sum and the component breakdown are retained so the data can be re-weighted if the subscription's weekly accounting is ever documented.
### Run loop and results
Each cell defaults to five trials, with a reporting floor of three.
Each run is bounded by a turn cap and a wall-clock backstop; exceeding either records a failure, tagged to distinguish a confused agent from a hung one.
Results are appended as immutable, timestamped samples to a per-cell store; deepening a cell adds samples rather than overwriting slots.
### Reporting
The aggregator reads the accumulated results and renders a headline table with one row per arm — cost-equivalent tokens, raw tokens, turns, duration, success rate, and a coverage figure — with imputed cost shown as a de-emphasized secondary column.
It renders whatever exists and annotates incomplete coverage rather than blocking on a full matrix.
Supporting views derived from the same records include a per-tier breakdown, a per-token-component breakdown, and the separate bonus table.
## Testing Decisions
A good test here exercises external behavior at a seam, not internal wiring, mirroring the repository's existing split between a deterministic fixture tier and a live end-to-end tier.
Three pure seams are unit-tested:
- The **checker**, fed synthetic state snapshots and expected states, covering the normalization rules (dropping volatile identifiers and timestamps, matching comments by author and body, comparing label sets), the full-state diff that catches both missing intent and collateral change, and the deterministic answer-match for read tasks.
- The **guard**, covering that each arm's allow-listed binary passes and that foreign binaries, absolute-path evasions, and interpreter-based fetch attempts are denied.
- The **aggregator**, covering partial-matrix annotation, coverage reporting, per-tier and per-component rollups, and stable rendering from an append-only sample store.
Two boundaries are validated by integration and audit rather than unit tests:
- **Seed provisioning** against the live host, validated by a smoke run rather than mocked, since its value is the real API interaction.
- **Run orchestration** against the real model, validated by the post-run transcript audit that asserts no foreign tool was reached; a detected leak flags the trial invalid.
Prior art for the deterministic seams is the project's existing fixture-server tier; prior art for the live boundary is the existing end-to-end tier, including its use of polling for the eventually-consistent issue indexer.
## Out of Scope
- The numbered task file that schedules this work is authored separately by the maintainer.
- Repository, release, milestone, and other operations outside gitea-axi's command surface are not scored; they appear only in the bonus table.
- Multi-account scenarios (distinct authors and assignees, and non-self approvals) are out of scope for the scored suite under the single-user constraint.
- Empirically pinning the subscription's exact weekly-budget weighting is a possible later validation, not part of this harness.
- Container-based operating-system isolation is not used; the guard is the enforcement mechanism.
## Further Notes
The gitea-mcp server uses a compact read/write dispatcher design rather than one eager schema per operation, so the MCP arm may not reproduce the dramatic token inflation seen for heavier MCP servers in the reference gh-axi benchmark.
That is a legitimate result about dispatcher-style MCP design, not a defect in the harness, and the expectation is set here so the outcome is not read as a bug.
Duration is treated as a soft metric throughout, because every arm runs against the live host and inherits its network variance.
The benchmark's own vocabulary (arm, cell, shared surface, cost-equivalent tokens, seed, checker) is intentionally kept in this spec and the `bench/` documentation rather than in the tool's domain glossary, which describes gitea-axi's own language and should not be diluted by harness terms.

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
@@ -98,7 +98,7 @@ This holds even when invoked by the SessionStart hook; error noise in non-Gitea
Always passes `type=issues` — Gitea's issue endpoints also serve PRs, which must never appear in issue lists (see Issue/PR Type Guard).
Client-side `--sort` reorders without changing membership, so the standard `count: N of T total` line is kept (see ADR 0005); full pagination still precedes sorting.
Default output fields (matching gh-axi): `number`, `title`, `state` (lowercased), `author` (plucked from `user.login`), `created` (relative time).
Extra fields via `--fields`: `body` (raw), `closedAt` (relative time, as `closed_at`), `labels` (joined names), `milestone` (title), `updatedAt` (relative time, as `updated_at`), `url`.
Extra fields via `--fields`: `body` (truncated at 500, exactly as `issue view` presents it; `--full` shows it raw), `closedAt` (relative time, as `closed_at`), `labels` (joined names), `milestone` (title), `updatedAt` (relative time, as `updated_at`), `url`.
No `type` field in output — Gitea has no issue types.
**`issue view <n> [flags]`**
@@ -118,7 +118,7 @@ No sub-issue augmentation (Gitea does not model issue hierarchies; use `issue bl
`--project` is excluded (Gitea has no projects REST API).
`--type` is excluded (Gitea has no issue types).
Output schema: `issue: { number, title, state, url }` (where `url` = `html_url`).
Extra fields available via `--fields`: `labels`, `assignees`, `milestone`, `body`.
Extra fields available via `--fields`: `labels`, `assignees`, `milestone`, `body` (truncated at 500, exactly as `issue view` presents it; `--full` shows it raw).
**`issue edit <n> [flags]`**
`--title`;
@@ -209,7 +209,7 @@ No gh-axi equivalent.
`--fields <a,b,c>`.
`--search` is explicitly forbidden (VALIDATION_ERROR; help: `` Use `gitea-axi search prs "<query>"` for full-text search ``).
Default output fields (matching gh-axi): `number`, `title`, `state` (lowercased), `author` (plucked from `user.login`), `draft` (bool→yes/no), `review` (`reviewDecision` mapped: APPROVED→approved, CHANGES_REQUESTED→changes_requested, REVIEW_REQUIRED→required).
Extra fields via `--fields`: `body` (raw), `createdAt` (relative time, as `created`), `labels` (joined names), `milestone` (title), `mergedAt` (relative time, as `merged_at`), `url`.
Extra fields via `--fields`: `body` (truncated at 500, exactly as `pr view` presents it; `--full` shows it raw), `createdAt` (relative time, as `created`), `labels` (joined names), `milestone` (title), `mergedAt` (relative time, as `merged_at`), `url`.
`reviewDecision` is computed client-side by fetching reviews for each PR in parallel (one extra HTTP call per PR; see ADR 0006).
When any client-side filter is active, the count line shows `count: N of T total` with `T` computed from the in-memory filtered result set (see ADR 0005).
@@ -361,7 +361,7 @@ Full-text search within the current repository, added because `--search` on the
The positional `<query>` is required (`VALIDATION_ERROR` if missing).
Endpoint: `GET /repos/issues/search` with `q=<query>`, `type=issues` or `type=pulls`, and `owner=<owner>`.
The endpoint has no repo-name filter, so results are additionally filtered client-side to the current repository via each result's `repository` field — the standard client-side filtering policy applies, including its `count: N of T total` rule with `T` from the filtered set.
Flags: `--state <open|closed|all>` (default open); `--label <name>` (API-supported — comma-separated names); `--limit <n>` (default 30); `--fields <a,b,c>`.
Flags: `--state <open|closed|all>` (default open); `--label <name>` (API-supported — comma-separated names); `--limit <n>` (default 30); `--fields <a,b,c>` (the same extras as `issue list` / `pr list`, so `body` is truncated at 500); `--full` (shows any `--fields body` raw, matching the list commands).
Default output fields (both commands): `number`, `title`, `state`, `author`, `created` — a locator schema; search results are Issue-shaped for both types, and `draft`/`review` parity with `pr list` would require two extra fetches per result for a command whose job is finding the number to feed into `issue view` / `pr view`.
Output blocks: `issues:` / `pull_requests:`, matching the list commands.
Empty state: standard `<noun>[0]: (none)`.
@@ -464,11 +464,15 @@ Field extraction uses a `FieldDef` type system with typed extractors: nested plu
**Principle 3 — Content truncation.**
Body text is truncated at **500 characters** in all contexts (list and detail alike), matching gh-axi.
"All contexts" is exhaustive: it includes the `body` field offered via `--fields` on `issue list`, `issue create`, `pr list`, and `search`, which truncate identically to `issue view` / `pr view` rather than emitting the body raw (task 0021).
The Command Surface once described that field as "`body` (raw)".
"Raw" is resolved here to mean *uncleaned markdown* (short bodies still pass through byte-for-byte, un-`cleanBody`-ed), never *unbounded* — an unbounded body on a list path would let `issue list --limit 30 --fields body` spill thirty full bodies into an agent's context, the exact cost this principle exists to prevent.
Comment bodies truncate at 800 characters wherever they appear (comment-post output and `--comments` view blocks), with cleanBody applied.
Diff content is truncated at 4000 characters.
When body truncation occurs, a hint is appended inline: `"... (truncated, N chars total - use --full to see complete body)"`.
When diff truncation occurs, `truncated: true` and `original_length: N` are added as separate fields, and a next-step suggestion to use `--full` is prepended.
`--full` on `issue view` and `pr view` suppresses all truncation in the command's output (entity body and comment bodies alike); `--full` on `pr diff` suppresses diff truncation.
`--full` on `issue list`, `issue create`, `pr list`, and `search` likewise suppresses the `--fields body` truncation, so the hint's "use --full to see complete body" holds on every command that offers the field.
Before truncation, a `cleanBody` step is applied **only when the raw body exceeds the truncation limit**.
`cleanBody` normalizes Gitea issue/PR URLs using the detected hostname (`https://<host>/<owner>/<repo>/issues/N` → `Issue#N`; `.../pulls/N` → `PR#N`), strips markdown image embeds, removes long URLs in markdown links and standalone text, and collapses email-style quoted blocks — matching gh-axi's transforms plus Gitea-specific URL normalization.
If cleaning brings the body within the limit, the cleaned body is returned with an appended note; if it still exceeds the limit, the cleaned body is truncated.
@@ -487,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:
@@ -512,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

@@ -13,9 +13,25 @@ Field selection: `--fields <a,b,c>` exposing the extra fields `body` (raw), `clo
## Acceptance criteria
- [ ] `--label`, `--assignee`, `--author`, and `--milestone` map to their Gitea API query params and filter server-side
- [ ] `--sort <created|updated|comments>` reorders descending client-side after full pagination; the count line still reports `T` from `X-Total-Count`
- [ ] `--fields` selects among the documented extra fields, each rendered via its FieldDef extractor (relative times, joined label names, milestone title)
- [ ] Output contains no `type` field
- [ ] `--search` fails with `VALIDATION_ERROR` (exit 2) and a help line pointing at `gitea-axi search issues "<query>"`
- [ ] Fixture-server tests cover each filter, client-side sort with pagination, `--fields` extraction, and the forbidden `--search`
- [x] `--label`, `--assignee`, `--author`, and `--milestone` map to their Gitea API query params and filter server-side
- [x] `--sort <created|updated|comments>` reorders descending client-side after full pagination; the count line still reports `T` from `X-Total-Count`
- [x] `--fields` selects among the documented extra fields, each rendered via its FieldDef extractor (relative times, joined label names, milestone title)
- [x] Output contains no `type` field
- [x] `--search` fails with `VALIDATION_ERROR` (exit 2) and a help line pointing at `gitea-axi search issues "<query>"`
- [x] Fixture-server tests cover each filter, client-side sort with pagination, `--fields` extraction, and the forbidden `--search`
## Implementation Notes
Exhaustive pagination landed as a shared `src/paginate.ts` (`fetchAllPages`, `readTotalCount`), since ADR 0005 makes it a policy that later slices (`pr list`, the dashboard) reuse rather than a detail of this command.
`lookup.ts`'s `listAllLabels` hand-rolled the same loop and now calls the shared helper, which removed its local `LABEL_PAGE_SIZE`/`LABEL_PAGE_LIMIT` constants.
The helper carries the 20-page cap those constants encoded, matching the 1000-item ceiling Principle 8 sets on exhaustive pagination — without it a server that ignores paging would loop forever.
Two count-line details the acceptance criteria did not spell out.
Under `--sort`, `--limit` caps the *sorted* result rather than the fetch, so pages are always read at the full page size of 50 and the top `N` by the sort key is what the limit selects.
Also under `--sort`, when an instance omits `X-Total-Count`, the total falls back to the size of the fully paginated set instead of degrading to `count: N (showing first N)` — everything was fetched to sort it, so the total is known, and Principle 4 says a total is always reported.
`--sort` and `--state` shared an enum-parsing shape, now extracted as `parseEnumFlag` in `flags.ts`.
**Open question for the spec, deliberately not resolved here:** `--fields body` renders the body raw and untruncated, exactly as this task and the spec's command surface specify ("`body` (raw)"), matching the already-merged `issue create --fields body`.
This contradicts Principle 3 ("Body text is truncated at **500 characters** in all contexts (list and detail alike)"): a 30-row list with `--fields body` can now emit 30 full bodies, which is the cost Principle 3 exists to prevent.
Truncating here alone would make `issue list` disagree with `issue create`, so the conflict wants one ruling applied to both commands rather than a silent divergence in this slice.

View File

@@ -15,12 +15,26 @@ No `type` field and no sub-issue augmentation.
## Acceptance criteria
- [ ] `issue view <n>` renders the default detail fields plus `comment_count` via renderDetail
- [ ] Bodies over 500 chars are cleaned then truncated with the inline hint `"... (truncated, N chars total - use --full to see complete body)"`; bodies at or under the limit pass through untouched
- [ ] cleanBody normalizes issue/PR URLs using the detected hostname, strips image embeds and long URLs, and collapses quoted blocks
- [ ] `--comments` renders every comment (no cap), each body cleaned and truncated at 800 chars
- [ ] `--full` returns raw, untruncated body and comment bodies
- [ ] A PR number yields `VALIDATION_ERROR` (exit 2) with the "is a pull request" message and a `pr view <n>` help line, detected via the fetched object's `pull_request` field
- [ ] A nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1)
- [ ] Single-entity next-step suggestions fill the actual issue number rather than a placeholder
- [ ] Fixture-server tests cover truncation boundaries, cleanBody transforms, `--comments`, `--full`, and the type guard
- [x] `issue view <n>` renders the default detail fields plus `comment_count` via renderDetail
- [x] Bodies over 500 chars are cleaned then truncated with the inline hint `"... (truncated, N chars total - use --full to see complete body)"`; bodies at or under the limit pass through untouched
- [x] cleanBody normalizes issue/PR URLs using the detected hostname, strips image embeds and long URLs, and collapses quoted blocks
- [x] `--comments` renders every comment (no cap), each body cleaned and truncated at 800 chars
- [x] `--full` returns raw, untruncated body and comment bodies
- [x] A PR number yields `VALIDATION_ERROR` (exit 2) with the "is a pull request" message and a `pr view <n>` help line, detected via the fetched object's `pull_request` field
- [x] A nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1)
- [x] Single-entity next-step suggestions fill the actual issue number rather than a placeholder
- [x] Fixture-server tests cover truncation boundaries, cleanBody transforms, `--comments`, `--full`, and the type guard
## Implementation Notes
The cleaning/truncation machinery lives in a new `src/body.ts` (`cleanBody`, `truncateBody`, and the `BODY_TRUNCATE_LIMIT`/`COMMENT_TRUNCATE_LIMIT` constants) so the later issue/PR slices can reuse it.
`renderDetail` was added to `src/render.ts` alongside `renderList`, sharing a private `encodeRows` helper for the `(none)` empty-block form.
`comment_count` is emitted only in the default view; when `--comments` is passed, the full `comments` block replaces it rather than sitting alongside a redundant scalar.
This matches gh-axi's documented behaviour ("with `--comments`, the comments block is appended") — the spec lists `comment_count` as a default field but does not require it to persist under `--comments`.
When there are no comments, `comment_count` renders as the bare number `0` (no `use --comments` hint, since there is nothing to expand).
The `--full` next-step suggestion fires whenever the rendered body differs from the raw body — i.e. it was cleaned-under-limit *or* truncated — read directly off the rendered output rather than recomputing the over-limit threshold, so the suggestion can never drift from `truncateBody`'s own decision.
The default detail fields (`number`, `title`, `state`, `author`, `created`) reuse the shared `FieldDef`/`extractRow` extraction from the list path; only `body` and `comment_count` are handled bespokely.
As a consequence the displayed `number` comes straight from the fetched issue rather than falling back to the requested number, which is safe because the get-issue endpoint always returns it.

View File

@@ -13,11 +13,31 @@ Create output is the entity block `issue: { number, title, state, url }` with ex
## Acceptance criteria
- [ ] `issue create --title` creates an issue and outputs `issue: { number, title, state, url }` where `url` is `html_url`
- [ ] Missing `--title` fails immediately with `VALIDATION_ERROR` (exit 2) before any API call
- [ ] `--body-file <path>` reads the body from a file; `--body` and `--body-file` together are rejected
- [ ] `--label` resolves each name to an ID via case-insensitive lookup against the repo's labels; an unknown name yields `VALIDATION_ERROR`
- [ ] `--milestone` resolves the name via the milestone query; an unknown name yields `VALIDATION_ERROR`
- [ ] `issue comment <n> --body` posts and outputs `comment: { number, author, created, body }` built from the POST response, body cleaned and truncated at 800 chars, where `number` is the issue number
- [ ] `issue comment` accepts a PR number without a type-guard error
- [ ] Fixture-server tests cover create with labels/milestone, both body sources, comment output shape, and each validation failure
- [x] `issue create --title` creates an issue and outputs `issue: { number, title, state, url }` where `url` is `html_url`
- [x] Missing `--title` fails immediately with `VALIDATION_ERROR` (exit 2) before any API call
- [x] `--body-file <path>` reads the body from a file; `--body` and `--body-file` together are rejected
- [x] `--label` resolves each name to an ID via case-insensitive lookup against the repo's labels; an unknown name yields `VALIDATION_ERROR`
- [x] `--milestone` resolves the name via the milestone query; an unknown name yields `VALIDATION_ERROR`
- [x] `issue comment <n> --body` posts and outputs `comment: { number, author, created, body }` built from the POST response, body cleaned and truncated at 800 chars, where `number` is the issue number
- [x] `issue comment` accepts a PR number without a type-guard error
- [x] Fixture-server tests cover create with labels/milestone, both body sources, comment output shape, and each validation failure
## Implementation Notes
**Shared machinery introduced here** (all of it is what `pr create` in task 0010 reuses):
`src/body-source.ts` (`resolveBodySource` / `requireBodySource`) resolves `--body` vs `--body-file`;
`src/lookup.ts` (`resolveLabelIds`, `resolveMilestoneId`) does name→ID resolution;
`parseFlags` grew a `repeatable` flag kind, accumulating occurrences into a separate `lists` map so `--label` can repeat without changing the type of the single-valued `flags` map;
`fields.ts` grew the `joined` array-join extractor and `selectExtraFields` for `--fields`.
**Label lookup paginates.** The spec just says `GET /labels`, but that endpoint pages (default 30), so a repo with more labels than one page would fail to resolve a perfectly valid name. `listAllLabels` pages at 50 until exhausted, with a 20-page (1000-label) runaway guard.
**`--fields` is additive, not a replacement.** The spec calls these "extra fields available via `--fields`", so the four default fields always render and `--fields` appends to them. An unknown name is a `VALIDATION_ERROR` listing the valid ones rather than being ignored.
**Milestone resolution re-checks the title client-side.** The `?name=` query only narrows the candidates; the returned title is then compared case-insensitively, so neither the caller's casing nor a looser server-side match (Gitea filters with a `LIKE`) can resolve to a milestone the caller did not name. Whether Gitea's filter is itself case-insensitive is the one assumption fixtures cannot settle, so the end-to-end tier now seeds a mixed-case label and milestone and passes both in a *different* case — if the live behaviour differs, CI fails rather than the user finding out.
**Deviation: `issue comment` also accepts `--full`.** The spec lists only `--body`/`--body-file` for it. But the shared 800-char truncation hint literally reads "use `--full` to see complete body", and without the flag that hint names a command that errors out. The flag is documented in `issue comment --help`.
**Deviation: no `issue view` suggestion after commenting on a PR.** `issue comment` is deliberately permissive toward PR numbers, but `issue view` type-guards them — so the obvious next-step suggestion would have been a command guaranteed to fail. The PR case is detected from the POST response's `pull_request_url` (no extra call) and falls back to the `--help` suggestion; `pr view` will be the right suggestion once task 0009 lands it.
**Follow-up worth flagging.** `src/commands/issue.ts` is now ~540 lines holding four subcommands, each with its own help text, field table, and suggestion builder. It's readable today, but tasks 00050007 add five more subcommands to it; a split into `src/commands/issue/<subcommand>.ts` is the natural next move, and is better done as its own refactor than smuggled into a feature task.

View File

@@ -15,10 +15,25 @@ All three use the action-block pattern on success (`edited:`/`closed:`/`reopened
## Acceptance criteria
- [ ] `issue edit` applies title, body, and milestone changes and outputs `edited: { number, status: "ok" }`
- [ ] `--add-label` posts the name directly to the additive label endpoint; `--remove-label` resolves the ID first, yields `VALIDATION_ERROR` for a name not in the repo, and treats a 404 for an unapplied label as silent success
- [ ] `--add-assignee`/`--remove-assignee` use fetch-then-patch, sending the full resulting assignee list in a single PATCH
- [ ] `issue close <n>` outputs `closed: { number, status: "ok" }`; with `--comment` the comment is posted after the close, and a comment-post failure surfaces as an error even though the issue is closed
- [ ] `issue close` on an already-closed issue and `issue reopen` on an already-open issue return early with `message: "Already closed"` / `message: "Already open"` and exit 0
- [ ] `issue reopen <n>` outputs `reopened: { number, status: "ok" }`
- [ ] Fixture-server tests cover each mutation path, both idempotent no-ops, the unapplied-label silent success, and the close-comment partial failure
- [x] `issue edit` applies title, body, and milestone changes and outputs `edited: { number, status: "ok" }`
- [x] `--add-label` posts the name directly to the additive label endpoint; `--remove-label` resolves the ID first, yields `VALIDATION_ERROR` for a name not in the repo, and treats a 404 for an unapplied label as silent success
- [x] `--add-assignee`/`--remove-assignee` use fetch-then-patch, sending the full resulting assignee list in a single PATCH
- [x] `issue close <n>` outputs `closed: { number, status: "ok" }`; with `--comment` the comment is posted after the close, and a comment-post failure surfaces as an error even though the issue is closed
- [x] `issue close` on an already-closed issue and `issue reopen` on an already-open issue return early with `message: "Already closed"` / `message: "Already open"` and exit 0
- [x] `issue reopen <n>` outputs `reopened: { number, status: "ok" }`
- [x] Fixture-server tests cover each mutation path, both idempotent no-ops, the unapplied-label silent success, and the close-comment partial failure
## Implementation Notes
No criteria were dropped or altered; all seven are satisfied.
Decisions made mid-implementation:
- The idempotent no-op for `close`/`reopen` renders an entity block — `issue: { number, state, message }` — mirroring gh-axi, since the spec's deliberate action-block departure is scoped to the *success* path only.
Determining the no-op requires a `GET` on the issue first, which also supplies the `state` reported in that block.
- `--add-label`/`--remove-label`/`--add-assignee`/`--remove-assignee` are repeatable, matching `issue create`'s repeatable `--label`, rather than the single-valued form the spec text implies.
- Added a `VALIDATION_ERROR` when `issue edit` is invoked with no changes (documented in `--help`). The spec never specified the no-change case; this is a small justified extension, not scope creep.
- Title/body/milestone and the recomputed assignee list travel in a single PATCH; label mutations use Gitea's dedicated endpoints afterward. Name resolution (milestone, remove-label IDs) runs before any mutation so a typo is reported before a change lands.
- Extracted a shared `getIssue` helper (review finding) now used by `view`/`edit`/`close`/`reopen`.
Follow-up worth flagging: `issue close`/`reopen` do not type-guard against a PR number (unlike `issue view`), consistent with the spec, which does not require it.

View File

@@ -12,8 +12,21 @@ The remaining simple issue mutations: `issue delete`, `issue pin`, `issue unpin`
## Acceptance criteria
- [ ] `issue delete <n>` outputs `issue: { number, status: "deleted" }` on success
- [ ] Deleting a nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1), not idempotent success
- [ ] `issue pin <n>` outputs `issue: { number, state, pinned }`; pinning an already-pinned issue returns early with `message: "Already pinned"` and exit 0
- [ ] `issue unpin <n>` mirrors pin with `message: "Already unpinned"` on the no-op
- [ ] Fixture-server tests cover delete success, delete-missing refusal, and both pin/unpin no-ops
- [x] `issue delete <n>` outputs `issue: { number, status: "deleted" }` on success
- [x] Deleting a nonexistent issue yields `ISSUE_NOT_FOUND` (exit 1), not idempotent success
- [x] `issue pin <n>` outputs `issue: { number, state, pinned }`; pinning an already-pinned issue returns early with `message: "Already pinned"` and exit 0
- [x] `issue unpin <n>` mirrors pin with `message: "Already unpinned"` on the no-op
- [x] Fixture-server tests cover delete success, delete-missing refusal, and both pin/unpin no-ops
## Implementation Notes
Pin state is read from the Gitea `pin_order` field, not a boolean: Gitea has no `pinned` flag on the issue struct, and records pin position as a positive integer (`0`/absent means unpinned).
A small `isPinned` helper wraps this so the two commands don't repeat the check.
The `state` field in the pin/unpin output is the issue's own open/closed state, taken from the fetched issue — pinning never changes it.
`issue delete` runs no confirmation prompt.
The review's Risk axis rated the change High solely because of the irreversible hard delete and suggested a `--yes` guard, but this is an agent-facing CLI with structured TOON output where interactive prompts don't fit, and the spec deliberately specifies a hard, non-idempotent delete without one.
Left unguarded by design; the destructiveness is inherent to the operation, not a defect.
`issuePin` and `issueUnpin` are near-identical mirrors (flagged as a judgement-call duplication by the Standards axis).
Kept as two functions per the repo's established one-function-per-subcommand convention, which the existing `issueClose`/`issueReopen` pair already follows.

View File

@@ -13,8 +13,22 @@ No gh-axi equivalent exists — the interface shape follows this spec alone.
## Acceptance criteria
- [ ] `issue blocks list <n>` and `issue blocked-by list <n>` render their respective output blocks with count lines and explicit empty states
- [ ] `issue blocks add <n> <target>` outputs `blocks: { issue: n, blocks: target }`; `issue blocked-by add <n> <blocker>` outputs `blocked_by: { issue: n, blocked_by: blocker }`
- [ ] Adding an existing relationship returns `already: true` (fetch-first check, no duplicate POST); removing a nonexistent relationship exits 0 silently-successfully
- [ ] Self-reference and cycle rejections from Gitea surface as `VALIDATION_ERROR` (exit 2) with the server's message
- [ ] Fixture-server tests cover list, add, idempotent re-add, remove, idempotent re-remove, and a 422 cycle rejection for both groups
- [x] `issue blocks list <n>` and `issue blocked-by list <n>` render their respective output blocks with count lines and explicit empty states
- [x] `issue blocks add <n> <target>` outputs `blocks: { issue: n, blocks: target }`; `issue blocked-by add <n> <blocker>` outputs `blocked_by: { issue: n, blocked_by: blocker }`
- [x] Adding an existing relationship returns `already: true` (fetch-first check, no duplicate POST); removing a nonexistent relationship exits 0 silently-successfully
- [x] Self-reference and cycle rejections from Gitea surface as `VALIDATION_ERROR` (exit 2) with the server's message
- [x] Fixture-server tests cover list, add, idempotent re-add, remove, idempotent re-remove, and a 422 cycle rejection for both groups
## Implementation Notes
Both groups are one config-parameterised implementation (`DependencyGroup`): `blocks` over `/issues/{index}/blocks`, `blocked-by` over `/issues/{index}/dependencies`, differing only in endpoint calls and the spec-fixed output names (`blocked_issues`/`blocking_issues`, `blocks`/`blocked_by`).
Decisions made mid-implementation, where the spec was silent:
- **`remove` output.** The spec fixes the `add` output shape but not `remove`'s. A successful deletion reports `<noun>: { issue, <target>, removed: true }`; a no-op removal (relationship already absent) reports `already: true` instead, mirroring `add`'s no-op and honouring the action/entity-block convention (a no-op reports the already-reached state rather than claiming an action it did not perform). Both `add` and `remove` are fetch-first against the fully paginated current set, so a nonexistent issue surfaces as `ISSUE_NOT_FOUND` before any mutation.
- **`list` row fields.** Rendered as `number`, `title`, `state` — the identifying essentials; the spec did not fix a row shape.
Review follow-ups addressed in this change:
- Extracted `parseIssueNumber` into `flags.ts` so the two-positional dependency parser and the existing `parsePositionalNumber` share one positive-integer rule and message (was duplicated).
- Added test coverage for the `ISSUE_NOT_FOUND` path (issue itself absent), which the code comments claim but nothing exercised.

View File

@@ -14,12 +14,20 @@ reviewDecision uses the official-first fallback: only official reviews count whe
## Acceptance criteria
- [ ] `pr list` renders the default fields with `review` computed from one parallel review fetch per PR
- [ ] reviewDecision honors the official-first fallback and maps to the three lowercase values, with zero-review and comment-only PRs rendering `required`
- [ ] `--label` resolves the name case-insensitively to an ID (`VALIDATION_ERROR` if unknown); `--label-id` bypasses the lookup
- [ ] `--author` and `--sort` map to their API params; `--sort` accepts the six Gitea values
- [ ] `--assignee`, `--base`, `--head`, and `--draft` filter client-side after full pagination, and the count line reports `count: N of T total` with `T` from the in-memory filtered set
- [ ] `--fields` exposes `body`, `createdAt`, `labels`, `milestone`, `mergedAt`, `url`
- [ ] `--search` fails with `VALIDATION_ERROR` (exit 2) pointing at `gitea-axi search prs "<query>"`
- [ ] Empty result emits `pull_requests[0]: (none)` plus a suggestion
- [ ] Fixture-server tests cover the review computation variants (official/unofficial, stale, dismissed), each client-side filter with its count line, the label lookup, and the forbidden flag
- [x] `pr list` renders the default fields with `review` computed from one parallel review fetch per PR
- [x] reviewDecision honors the official-first fallback and maps to the three lowercase values, with zero-review and comment-only PRs rendering `required`
- [x] `--label` resolves the name case-insensitively to an ID (`VALIDATION_ERROR` if unknown); `--label-id` bypasses the lookup
- [x] `--author` and `--sort` map to their API params; `--sort` accepts the six Gitea values
- [x] `--assignee`, `--base`, `--head`, and `--draft` filter client-side after full pagination, and the count line reports `count: N of T total` with `T` from the in-memory filtered set
- [x] `--fields` exposes `body`, `createdAt`, `labels`, `milestone`, `mergedAt`, `url`
- [x] `--search` fails with `VALIDATION_ERROR` (exit 2) pointing at `gitea-axi search prs "<query>"`
- [x] Empty result emits `pull_requests[0]: (none)` plus a suggestion
- [x] Fixture-server tests cover the review computation variants (official/unofficial, stale, dismissed), each client-side filter with its count line, the label lookup, and the forbidden flag
## Implementation Notes
- The `reviewDecision` computation lives in a new `src/review.ts` module (`reviewDecision` pure core + `fetchReviewDecision` I/O shell), so `pr view` and the dashboard can reuse the same policy in later slices (ADR 0006).
- Added a `boolText` field extractor to `src/fields.ts` for the `draft` bool→yes/no column; it is part of the shared field vocabulary rather than inlined, since `pr view` renders `draft` too.
- Extracted `parsePositiveInt` into `src/flags.ts` and routed `pr list`'s `--limit`/`--label-id` and `issue list`'s `--limit` through it, collapsing three copies of the same positive-integer parse into one (a review finding). Behaviour and error wording are unchanged.
- Small unrequested robustness kept deliberately: `--label-id` accepts a comma-separated list (mirroring `--label`), and `--label` + `--label-id` may be combined — their resolved IDs concatenate. The spec describes each as a single value; this is a strict superset with no behaviour change for the single-value case.
- The `url` extra field plucks `html_url` (the browsable URL), matching `issue list`'s precedent rather than Gitea's API `url` field.

View File

@@ -13,11 +13,21 @@ The `checks` field renders as the summary string (`N passed, N failed[, N skippe
## Acceptance criteria
- [ ] `pr view <n>` renders the default fields including `checks`, `comment_count`, and `review_count` from the three-call fetch pattern
- [ ] Commit-status states map to the four conclusions per the spec, `warning` counting as failure
- [ ] A PR with no statuses renders the `"0 passed, 0 failed — this PR has no CI checks configured"` message in both commands
- [ ] `--reviews` lists reviews with `official` and `stale` fields plus their inline comments
- [ ] `--comments` and `--full` behave as on `issue view` (800-char comment truncation with cleanBody; `--full` suppresses everything)
- [ ] `pr checks <n>` outputs the summary line followed by `{ name, conclusion }` rows
- [ ] A nonexistent PR yields `PR_NOT_FOUND` (exit 1)
- [ ] Fixture-server tests cover the status mapping including `skipped` and `warning`, the no-CI case, `--reviews`, and truncation behavior
- [x] `pr view <n>` renders the default fields including `checks`, `comment_count`, and `review_count` from the three-call fetch pattern
- [x] Commit-status states map to the four conclusions per the spec, `warning` counting as failure
- [x] A PR with no statuses renders the `"0 passed, 0 failed — this PR has no CI checks configured"` message in both commands
- [x] `--reviews` lists reviews with `official` and `stale` fields plus their inline comments
- [x] `--comments` and `--full` behave as on `issue view` (800-char comment truncation with cleanBody; `--full` suppresses everything)
- [x] `pr checks <n>` outputs the summary line followed by `{ name, conclusion }` rows
- [x] A nonexistent PR yields `PR_NOT_FOUND` (exit 1)
- [x] Fixture-server tests cover the status mapping including `skipped` and `warning`, the no-CI case, `--reviews`, and truncation behavior
## Implementation Notes
- The checks machinery lives in a new `src/checks.ts`: `summarizeChecks` (pure state→conclusion mapping + summary line) and `fetchChecks` (I/O shell), so `pr view` renders only the summary line while `pr checks` renders the summary plus the `{ name, conclusion }` rows from the same core. Unknown/future commit-status states fall through to `pending` rather than being reported as a pass or fail they are not.
- `pr checks` output shape follows gh-axi: when checks exist, the summary is the lead line above a `checks` list block (rendered via `renderList`'s lead-line slot); when none exist, a scalar `checks: <message>` line. A new generic `renderScalar(noun, value, help)` in `src/render.ts` emits that literal line + help, keeping the value unquoted (as TOON reads a string scalar to end of line), matching the summary line's treatment.
- `pr view` uses the three-call pattern from ADR 0006: the PR and its reviews are fetched in parallel, then the combined status once the head SHA is known. `review.ts` grew `fetchReviews` (raw reviews, now the shared base of `fetchReviewDecision`) and `fetchReviewComments` (per-review inline comments for `--reviews`).
- `commentRows` was extracted from `issue.ts` into `src/comment.ts` and is now shared by `issue view --comments` and `pr view --comments`, removing the duplicate row builder.
- `merged` renders as `no` when open, or the merge time (relative) once merged, matching gh-axi's "no / mergedAt value" behavior.
- Review inline-comment rows (`{ author, path, body }`) are built inline in `buildReviewRows` rather than through the shared `commentRows` (`{ author, created, body }`): they are a different entity (`PullReviewComment`, with `path` and no displayed timestamp), so forcing reuse would have meant parameterizing the middle field — a shared helper here would obscure more than it saves. Flagged by the Standards review as a judgement call; kept separate deliberately.
- The no-CI summary is labeled `summary:` when checks exist but `checks:` when none do. This matches the spec's literal empty-state form (`checks: "0 passed, 0 failed — …"`, spec line 292) and gh-axi's shape, so the label difference is intentional rather than an inconsistency.

View File

@@ -14,10 +14,53 @@ Success output is the action block `created: { number, url }` — action block w
## Acceptance criteria
- [ ] `pr create --title` creates a PR and outputs `created: { number, url }`
- [ ] Omitted `--head` resolves to the current local branch; omitted `--base` resolves to the repo's default branch
- [ ] An existing open PR for the same branch pair short-circuits to `pull_request: { number, url, already: true }` with no duplicate created
- [ ] `--label` and `--milestone` resolve names case-insensitively with `VALIDATION_ERROR` on unknown names; `--assignee` and `--reviewer` pass through
- [ ] `pr comment <n> --body` outputs `comment: { number, author, created, body }` from the POST response, body truncated at 800 chars
- [ ] Missing required inputs (`--title` on create, body on comment) fail with `VALIDATION_ERROR` (exit 2) before any API call
- [ ] Fixture-server tests cover creation with defaults, the idempotent short-circuit, name resolution failures, and the comment output shape
- [x] `pr create --title` creates a PR and outputs `created: { number, url }`
- [x] Omitted `--head` resolves to the current local branch; omitted `--base` resolves to the repo's default branch
- [x] An existing open PR for the same branch pair short-circuits to `pull_request: { number, url, already: true }` with no duplicate created
- [x] `--label` and `--milestone` resolve names case-insensitively with `VALIDATION_ERROR` on unknown names; `--assignee` and `--reviewer` pass through
- [x] `pr comment <n> --body` outputs `comment: { number, author, created, body }` from the POST response, body truncated at 800 chars
- [x] Missing required inputs (`--title` on create, body on comment) fail with `VALIDATION_ERROR` (exit 2) before any API call
- [x] Fixture-server tests cover creation with defaults, the idempotent short-circuit, name resolution failures, and the comment output shape
## Implementation Notes
**Reused wholesale from task 0004.**
`resolveBodySource`/`requireBodySource`, `resolveLabelIds`/`resolveMilestoneId`, and the `repeatable` flag kind all carried over untouched — `pr create` added no new shared machinery of its own beyond what is listed below.
**New shared machinery.**
`src/comment.ts` (`COMMENT_FLAGS`, `commentItem`) now owns the `comment: { number, author, created, body }` block that ADR 0008 requires `issue comment` and `pr comment` to emit identically; it existed twice after the first draft, which is exactly the drift that ADR forbids, so it was extracted and `issue comment` moved onto it.
`parsePositionalNumber` (in `src/flags.ts`) replaces `issue.ts`'s private `parseIssueNumber`, taking the noun ("issue", "pull request") as a parameter; the issue-side messages are unchanged.
`httpStatus` (in `src/errors.ts`) exposes the status of a failed call for the callers that give one status a meaning of their own before falling back to `classifyHttpError`.
`currentBranch` (in `src/git.ts`) reads `git rev-parse --abbrev-ref HEAD`, as the spec names.
**A closed PR for the same branch pair does not short-circuit.**
Gitea's by-base-head lookup matches on the branches alone, so it can answer with a closed or merged PR.
The spec says the check is for "an existing *open* PR", and the branches of a closed one are free to be proposed again, so only an open PR short-circuits.
**Names are resolved before the existence check, not after.**
Whether a label name is real does not depend on remote state, so a typo is reported the same way whether or not the PR already exists.
The alternative ordering saves one API call on the short-circuit path but makes a misspelled `--label` fail on the first run and pass silently on the second.
**Deviation: `pr comment` also accepts `--full`.**
The spec lists only `--body`/`--body-file` for it, but the shared 800-char truncation hint reads "use `--full` to see complete body", and without the flag that hint names a command that errors out.
This is the same deviation, for the same reason, that `issue comment` took in task 0004.
**Deviation: a 404 from `pr comment` is `PR_NOT_FOUND`, not `ISSUE_NOT_FOUND`.**
The spec's status table classifies 404s by path, and PR comments go through `/issues/{index}/comments`, which would report a missing PR as a missing issue.
The table's own header is "HTTP status | Context | Error code", and the command knows its target is a pull request, so the calling context wins over the path here.
**Deviation: next-step suggestions point at `pr comment`, not `pr view`.**
gh-axi's reference suggests `pr view <id>` after both commands, but `pr view` does not exist until task 0009, and task 0004 already established that this tool does not hand back a command guaranteed to fail.
**Follow-up:** task 0009 should upgrade the `pr create` and `pr comment` help lines to `pr view` once it lands.
**Beyond the ask: the end-to-end tier.**
The criteria call only for fixture-server tests, but fixtures can only replay an answer they were told to give, and the whole idempotency check rests on how live Gitea's by-base-head lookup actually behaves (404 when no PR matches; the open PR when one does).
`test/e2e/mutations.test.ts` now seeds a branch and asserts both against a live instance, so a wrong assumption fails CI rather than surfacing as a duplicate PR.
The two e2e suites share one provisioned instance via `instanceOnce()`.
**Review findings left unaddressed.**
The optional-field payload assembly in `prCreate` mirrors `issueCreate`'s, and `repoOnBranch`/`gitEnv` in `test/pr-create.test.ts` overlap with `detection.test.ts`'s private git-sandbox helpers; both are shapes rather than logic, and collapsing them would mean either a generic `assignDefined` helper or dragging the fake-`tea` sandbox machinery into `harness.ts`.
Left alone deliberately, to be revisited if a third caller appears.
**Follow-up worth flagging.**
Coverage is now 95.9% statements / 90.1% branches against thresholds of 92/87; the ratchet in `vitest.config.ts` invites raising them, but that belongs in its own commit rather than a feature task, as the last raise was.

View File

@@ -14,9 +14,54 @@ Success outputs follow the action-block pattern: `edited:`/`closed:`/`reopened:`
## Acceptance criteria
- [ ] `pr edit` applies title, body, milestone, and base changes and outputs `edited: { number, status: "ok" }`
- [ ] Label and assignee mutations follow the same rules as `issue edit` (additive endpoints, fetch-then-patch, unapplied-label silent success)
- [ ] `--add-reviewer`/`--remove-reviewer` call the requested-reviewers endpoints with `{ reviewers: [login] }`
- [ ] `pr close --comment` posts the comment after the PATCH and surfaces a comment failure; closing an already-closed-or-merged PR returns the entity block with `already: true`
- [ ] `pr reopen` on an open PR returns the entity block with `already: true`; otherwise outputs `reopened: { number, status: "ok" }`
- [ ] Fixture-server tests cover reviewer add/remove, the merged-PR close no-op, and the reopen paths
- [x] `pr edit` applies title, body, milestone, and base changes and outputs `edited: { number, status: "ok" }`
- [x] Label and assignee mutations follow the same rules as `issue edit` (additive endpoints, fetch-then-patch, unapplied-label silent success)
- [x] `--add-reviewer`/`--remove-reviewer` call the requested-reviewers endpoints with `{ reviewers: [login] }`
- [x] `pr close --comment` posts the comment after the PATCH and surfaces a comment failure; closing an already-closed-or-merged PR returns the entity block with `already: true`
- [x] `pr reopen` on an open PR returns the entity block with `already: true`; otherwise outputs `reopened: { number, status: "ok" }`
- [x] Fixture-server tests cover reviewer add/remove, the merged-PR close no-op, and the reopen paths
## Implementation Notes
No criteria were dropped or altered; all six are satisfied.
Decisions made mid-implementation:
- The close no-op reports the actual state: `merged` for a merged PR (whose raw
`state` Gitea reports as `closed`), otherwise the raw state — computed by a
small `pullState` helper. `pull.state === "closed"` catches both the closed and
merged cases for the short-circuit, matching the spec's "already closed or
merged".
- `pr reopen` short-circuits only on `state === "open"`, per the spec. A merged
PR (state `closed`) therefore falls through to the PATCH and Gitea rejects it as
a `VALIDATION_ERROR` — the spec asks for no merged-guard on reopen, mirroring the
issue side.
- Reviewer mutations are one POST for all `--add-reviewer` and one DELETE for all
`--remove-reviewer`, each carrying the whole list — the requested-reviewers
endpoints take arrays (ADR 0007 amendment). They are not fetch-then-patch and
are not idempotency-checked; a redundant add/remove surfaces whatever Gitea
answers.
- `--add-label`/`--remove-label`/`--add-assignee`/`--remove-assignee`/`--add-reviewer`/`--remove-reviewer`
are repeatable, matching `issue edit`.
- Name resolution (milestone, remove-label ids) runs before any mutation so a typo
is reported before a change lands. Title/body/base/milestone and the recomputed
assignee list travel in a single PATCH; labels and reviewers use their dedicated
endpoints afterward.
- Added a `VALIDATION_ERROR` when `pr edit` is invoked with no changes, matching
`issue edit`.
- Review finding (Duplicated Code): extracted the fetch-then-patch merge into a
shared `src/assignees.ts` — a pure `mergeAssignees` plus an `assigneeLogins`
reader — now used by both `issue edit` and `pr edit`, replacing the inline copy
that previously lived in `issue.ts`.
Follow-ups worth flagging (unaddressed review findings, both judgement calls):
- The close/reopen state-machine (read state → no-op short-circuit → PATCH `{state}`
→ render) is still duplicated between `issue.ts` and `pr.ts`. A shared helper was
left unextracted because the two sides diverge in their no-op shape, help
suggestions, and the PR-only merged handling, which would make the abstraction
leaky.
- The no-op output shape differs between the issue side (`message: "Already
closed"`) and the PR side (`already: true` + `state`). This is spec-driven — the
spec fixes `already: true` for PRs — but the CLI's no-op output is not uniform
across the two entities.

View File

@@ -14,10 +14,26 @@ Merge-blocked conditions surface through the standard 405/409 → `VALIDATION_ER
## Acceptance criteria
- [ ] `--method` accepts all six methods and the shorthands map to their methods; conflicting or duplicate action flags yield `VALIDATION_ERROR` (exit 2) before any API call
- [ ] `--merge-commit-id` without `manually-merged`, or `manually-merged` without `--merge-commit-id`, both yield `VALIDATION_ERROR` locally
- [ ] Successful merge outputs `merged: { number, status: "ok", method }`
- [ ] An already-merged PR short-circuits to the entity block with `merged_by` and `merged_at`, exit 0, no merge API call
- [ ] A 405 not-mergeable response surfaces as `VALIDATION_ERROR` with help suggesting `pr update-branch <n>` or `pr checkout <n>`
- [ ] `pr update-branch <n> --style rebase` calls the update endpoint with the style param and outputs `updated: { number, status: "ok" }`
- [ ] Fixture-server tests cover each method, the local validations, the idempotent no-op, and the 405/409 mappings
- [x] `--method` accepts all six methods and the shorthands map to their methods; conflicting or duplicate action flags yield `VALIDATION_ERROR` (exit 2) before any API call
- [x] `--merge-commit-id` without `manually-merged`, or `manually-merged` without `--merge-commit-id`, both yield `VALIDATION_ERROR` locally
- [x] Successful merge outputs `merged: { number, status: "ok", method }`
- [x] An already-merged PR short-circuits to the entity block with `merged_by` and `merged_at`, exit 0, no merge API call
- [x] A 405 not-mergeable response surfaces as `VALIDATION_ERROR` with help suggesting `pr update-branch <n>` or `pr checkout <n>`
- [x] `pr update-branch <n> --style rebase` calls the update endpoint with the style param and outputs `updated: { number, status: "ok" }`
- [x] Fixture-server tests cover each method, the local validations, the idempotent no-op, and the 405/409 mappings
## Implementation Notes
**No merge method given → `Do: merge` on the wire, `method: default` in the output.**
Gitea's merge endpoint requires a concrete `Do`, so with no `--method`/shorthand the command sends the baseline `merge` while reporting `method: "default"`.
The reported field describes the caller's choice (none was made), matching the gh-axi interface's documented shape; it is not a seventh merge method.
A consequence worth flagging: a repository configured to disallow plain merge commits (e.g. squash-only) will reject a bare `pr merge` with a 405, which surfaces with the server's message.
Respecting the repo's `default_merge_style` on the no-method path (an extra repo GET) is a possible follow-up if that turns out to bite.
**Conflicting/duplicate method flags share one message.**
Any combination of more than one method selector (`--method`, `--merge`, `--squash`, `--rebase`) yields a single `VALIDATION_ERROR`: `Choose only one merge method (--method, --merge, --squash, or --rebase)`.
The gh-axi interface doc lists three separate strings (multiple shorthands, `--method`+shorthand, invalid value); the combined message covers the first two cases in one and reads at least as clearly, and the task's own criteria only require `VALIDATION_ERROR` before any API call.
**`--merge-commit-id` remediation and 405/409 handling.**
`manually-merged` is reachable only through `--method` (it has no shorthand), so the `--merge-commit-id` pairing check can never collide with a shorthand.
Merge-blocked 405/409 responses reuse `classifyHttpError`'s `VALIDATION_ERROR` mapping (preserving the server's message) but swap in two remediation help lines pointing at `pr update-branch <n>` and `pr checkout <n>` — the latter lands in task 0014, so the suggestion currently names a command that does not exist yet.

View File

@@ -12,8 +12,35 @@ Output: `review: { number, action }`.
## Acceptance criteria
- [ ] Each action flag submits the corresponding review event and outputs `review: { number, action }`
- [ ] Zero action flags, or more than one, yield `VALIDATION_ERROR` (exit 2) with no API call
- [ ] A server-side 422 for a missing body surfaces as `VALIDATION_ERROR` carrying Gitea's message
- [ ] `--body-file` works as everywhere else
- [ ] Fixture-server tests cover all three actions, the flag-count validations, and the 422 passthrough
- [x] Each action flag submits the corresponding review event and outputs `review: { number, action }`
- [x] Zero action flags, or more than one, yield `VALIDATION_ERROR` (exit 2) with no API call
- [x] A server-side 422 for a missing body surfaces as `VALIDATION_ERROR` carrying Gitea's message
- [x] `--body-file` works as everywhere else
- [x] Fixture-server tests cover all three actions, the flag-count validations, and the 422 passthrough
## Implementation Notes
`pr review` follows the established `pr merge`/`pr comment` shape: `resolveReviewAction`
collects the set of action switches (`--approve`/`--request-changes`/`--comment`) mapped
through the `REVIEW_ACTIONS` record and raises `VALIDATION_ERROR` when the count is not
exactly one — the direct analogue of `resolveMergeMethod`'s conflicting-selector rule the
spec references, adapted from "at most one (with a default)" to "exactly one (no default)".
The action flag count is settled before `createClient`, so an invalid invocation never
reaches the API. The body flows through the shared `resolveBodySource`, so `--body`/
`--body-file` and their mutual exclusion behave as everywhere else, and no body requirement
is pre-validated locally: a body-less event Gitea rejects returns 422, which the shared
`classifyHttpError` already maps to `VALIDATION_ERROR` carrying the server's message.
Deviations from the literal spec, both deliberate:
- The success block appends a `pr view <n> --reviews` suggestion line via `renderDetail`'s
`help`. The spec's output contract names only `review: { number, action }`; the extra
hint is the house style every sibling mutation command (`merge`, `edit`, `close`, …)
already follows, so it was kept for consistency rather than trimmed to the bare contract.
- A named `ReviewAction` interface was introduced in place of a repeated inline
`{ event; action }` shape, following a Standards-axis review nit — it matches the local
convention of naming such types (`MergeMethod`, `UpdateStyle`).
Built test-first via the `test-driven-development` skill (test-writer sub-agent, one
behavior per RED→GREEN cycle); `test/pr-review.test.ts` holds 10 tests. Full suite: 311
passing, typecheck clean.

View File

@@ -15,9 +15,29 @@ Output: `checkout: { number, branch, status: "ok" }`.
## Acceptance criteria
- [ ] `pr diff <n>` outputs the diff, adding `truncated: true` and `original_length` when over 4000 chars plus a `--full` next-step suggestion; `--full` returns the raw diff
- [ ] `pr checkout <n>` handles all three local-branch cases and re-running it is idempotent
- [ ] A checked-out branch that has diverged from the PR head fails with `GIT_ERROR` and an explanatory help line, leaving local commits intact
- [ ] Other git failures (dirty worktree, network) map to `GIT_ERROR` with git's first stderr line
- [ ] Checkout works for a fork PR whose head repo is not a configured remote (via `refs/pull/{n}/head`)
- [ ] Tests cover diff truncation boundaries and the three checkout cases (git behavior exercised against a scratch repository, API responses from the fixture server)
- [x] `pr diff <n>` outputs the diff, adding `truncated: true` and `original_length` when over 4000 chars plus a `--full` next-step suggestion; `--full` returns the raw diff
- [x] `pr checkout <n>` handles all three local-branch cases and re-running it is idempotent
- [x] A checked-out branch that has diverged from the PR head fails with `GIT_ERROR` and an explanatory help line, leaving local commits intact
- [x] Other git failures (dirty worktree, network) map to `GIT_ERROR` with git's first stderr line
- [x] Checkout works for a fork PR whose head repo is not a configured remote (via `refs/pull/{n}/head`)
- [x] Tests cover diff truncation boundaries and the three checkout cases (git behavior exercised against a scratch repository, API responses from the fixture server)
## Implementation Notes
The raw diff is fetched through the generated client's `repoDownloadPullDiffOrPatch`, but with `{ format: "text" }` forced per call.
The `giteaApi` wrapper sets `baseApiParams.format: "json"`, so every response otherwise runs through `response.json()` — which would discard a plain-text `.diff` body and leave `data` null.
Forcing `text` reads the diff verbatim.
To let the fixture server return a non-JSON diff body, `FixtureServer`'s route gained a `raw?: string` field, served verbatim as `text/plain` (bypassing the `JSON.stringify` the other fields get).
`PULL_PATH` in `src/errors.ts` was widened from `(\d+)(?:\/|$)` to `(\d+)(?:[./]|$)` so a 404 on `/pulls/{n}.diff` still classifies as `PR_NOT_FOUND` rather than falling through to `REPO_NOT_FOUND` — the diff endpoint's number is followed by a `.` suffix rather than a `/` or end-of-path.
Diff truncation is its own `truncateDiff` in `src/diff.ts`, deliberately not reusing `truncateBody`: it signals the cut with separate `truncated`/`original_length` fields (so the diff text stays a verbatim prefix) rather than the inline hint bodies use, and does no body-cleaning.
For the checked-out-and-diverged case, git's `merge --ff-only` prints its `hint:` lines to stderr before the `fatal:` line, so the surfaced `GIT_ERROR` message is that first `hint:` line; the plain-language divergence explanation and remediation live in the help lines, which is where the acceptance criterion's "explanatory help line" is asserted.
This matches the spec's "carrying git's first stderr line" literally.
`runGit` (the shared git-runner that maps a non-zero exit to `GIT_ERROR` with git's first stderr line) gained an optional `fallbackMessage` argument during the `/review-uncommitted` pass, so the `merge --ff-only` step routes through it instead of re-implementing the enoent/non-zero mapping inline (a Duplicated-Code judgement call the Standards axis raised).
Process note: `/implement` front-loaded the implementation before the test-writer sub-agent authored the tests, so each TDD cycle was green-on-first-run rather than red-first.
Every test was still authored independently by a `general-purpose` sub-agent from the public CLI interface alone (it never read the implementation source), one behavior at a time.

View File

@@ -13,10 +13,40 @@ The label command group: `label list`, `label create`, `label edit`, `label dele
## Acceptance criteria
- [ ] `label list` renders the count line and `labels:` block; empty repos get the explicit empty state
- [ ] `label create --name --color` creates the label, prepending `#` to the color, and outputs `created: ok` + `label: <name>`
- [ ] Creating an existing label (case-insensitive) outputs `create: already_exists` + the existing name, exit 0
- [ ] `label edit <name>` applies `--name`/`--color`/`--description` and outputs `edit: ok` + the resulting name
- [ ] `label edit`/`label delete` on an unknown name yield `VALIDATION_ERROR` (exit 2)
- [ ] `label delete <name>` outputs `delete: ok` + `label: <name>`
- [ ] Fixture-server tests cover create, idempotent re-create, edit, delete, and the unknown-name refusals
- [x] `label list` renders the count line and `labels:` block; empty repos get the explicit empty state
- [x] `label create --name --color` creates the label, prepending `#` to the color, and outputs `created: ok` + `label: <name>`
- [x] Creating an existing label (case-insensitive) outputs `create: already_exists` + the existing name, exit 0
- [x] `label edit <name>` applies `--name`/`--color`/`--description` and outputs `edit: ok` + the resulting name
- [x] `label edit`/`label delete` on an unknown name yield `VALIDATION_ERROR` (exit 2)
- [x] `label delete <name>` outputs `delete: ok` + `label: <name>`
- [x] Fixture-server tests cover create, idempotent re-create, edit, delete, and the unknown-name refusals
- [x] End-to-end tests exercise the live-Gitea semantics the fixture server cannot attest to: the `#`-prefixed color round-trip on create, idempotency against the live listing, and name→id resolution behind edit/delete (plus the not-idempotent delete refusal)
## Implementation Notes
Built the `label` group in `src/commands/label.ts`, wired into `src/cli.ts` (dispatcher plus the two most-common entries in the top-level help), following the sibling `issue`/`pr` command patterns.
Deviations and decisions:
- **`label edit` requires at least one change.**
The spec/gh-axi reference leaves all edit flags optional, but sending an empty `PATCH` is a pointless call, so an edit with none of `--name`/`--color`/`--description` is refused with `VALIDATION_ERROR` — mirroring `issue edit`'s "requires at least one change" guard for consistency within gitea-axi.
- **Resulting name for `edit`/`create`/`delete` comes from the API response** (`edited.name`, `label.name`), not the input, so the reported name is the server's canonical echo (correct casing, and the unchanged original when `--name` was omitted).
- **`create` vs `created` output keys.**
The success key is `created: ok` and the idempotent-hit key is `create: already_exists` — two different top-level keys.
This is spec-mandated (spec lines 342343) rather than the `already: true` shape the dependency no-ops use; kept verbatim to match the fixed contract.
- **Flat `renderObject` output shape.**
Added `renderObject(item, help)` to `src/render.ts` because the label create/edit/delete outputs are flat top-level fields (`created: ok` / `label: <name>`), which the sibling `renderDetail` (nests under a `noun:` block) cannot produce. This is the spec's output shape, not a new convention chosen freely.
- **Shared lookup + positional helpers (cleanups from review).**
Extracted `findLabel`/`resolveLabel` and a shared `labelNotFound` message into `src/lookup.ts`, reused by the existing `resolveLabelIds`; and extracted `parseSinglePositional` in `src/flags.ts`, now shared by `parsePositionalNumber` and the label name positionals, removing the duplicated count-check/error scaffolding.
- **`label list` uses a single-page fetch with `--limit` (default 500)**, reading `X-Total-Count` for the count line, rather than the exhaustive pagination `resolveLabel`/`resolveLabelIds` use; the spec asks only for `--limit`, and a repo with >500 labels is signalled by the `count: N of T total` line.
### Follow-ups added after review
- **End-to-end tier extended.**
The task originally scoped its tests to the fixture-server tier only, matching the precedent of the preceding PR-command tasks (00110014), none of which added e2e cases.
On reflection the label commands sit squarely inside the e2e tier's charter — "behavior the fixture server cannot attest to" — because a fixture server never enforces that Gitea's `CreateLabelOption.color` requires the leading `#`, nor that edit/delete really key on the numeric label id.
Added a `test/e2e/mutations.test.ts` label-lifecycle case (create → idempotent re-create → edit → delete, verified against live state via a new `fetchLabels` provisioner helper) plus the not-idempotent delete refusal.
These run only in CI (gated on `GITEA_AXI_E2E_URL`).
- **Unit-tier coverage backfill.**
The initial fixture tests covered only the happy paths and unknown-name refusals, leaving the help output, validation errors, and API-error propagation untested — enough to drop the repo below its global branch-coverage gate.
Added confirming fixture tests for those behaviors, bringing `src/commands/label.ts` to ~96% line / ~86% branch and the suite back over its thresholds.

View File

@@ -13,9 +13,21 @@ Both commands use the locator schema (`number`, `title`, `state`, `author`, `cre
## Acceptance criteria
- [ ] `search issues "<query>"` and `search prs "<query>"` query the search endpoint with the right `type` and owner, then filter to the current repo client-side
- [ ] The count line reports `count: N of T total` with `T` from the client-side-filtered set
- [ ] A missing query yields `VALIDATION_ERROR` (exit 2)
- [ ] `--state`, `--label`, `--limit`, and `--fields` work; default fields are the locator schema
- [ ] Empty results emit the standard `<noun>[0]: (none)` empty state
- [ ] Fixture-server tests cover both types, cross-repo results being filtered out, and the missing-query validation
- [x] `search issues "<query>"` and `search prs "<query>"` query the search endpoint with the right `type` and owner, then filter to the current repo client-side
- [x] The count line reports `count: N of T total` with `T` from the client-side-filtered set
- [x] A missing query yields `VALIDATION_ERROR` (exit 2)
- [x] `--state`, `--label`, `--limit`, and `--fields` work; default fields are the locator schema
- [x] Empty results emit the standard `<noun>[0]: (none)` empty state
- [x] Fixture-server tests cover both types, cross-repo results being filtered out, and the missing-query validation
- [x] End-to-end tests run `search issues` and `search prs` against a live Gitea instance and assert real matches are returned with the locator schema, confirming the live search-endpoint response shape and the `type`/`owner`/`q` query behavior the fixture server cannot attest to
## Implementation Notes
- Both variants live in one `src/commands/search.ts`, parameterised by a `SearchKind` config (`type`, output `noun`, the `view` command a match feeds into, and `--help` text) — the same config-object dispatch used elsewhere (`pr.ts`'s `DependencyGroup`).
`search issues` and `search prs` share the endpoint call, the client-side repo filter, and the render, differing only in that config.
- The repo filter is *always* client-side (the endpoint has no repo-name param), so every call fully paginates via `fetchAllPages` and then filters to the current repo by matching each result's `repository.owner`/`repository.name` case-insensitively.
The count-line total `T` is the filtered set's own size, so the endpoint's cross-repo `X-Total-Count` is never used — the ADR 0005 client-side-filtering rule.
- `--limit` caps the shown rows *after* filtering while `T` keeps the full filtered total, matching `pr list`'s client-filter behaviour.
- `--label` is passed straight through as the endpoint's `labels` param (comma-separated names): the search endpoint takes names directly, so there is no name→id lookup, unlike `pr list --label`.
- The `--fields` extra-field vocabulary (`body`, `closedAt`, `labels`, `milestone`, `updatedAt`, `url`) mirrors `issue list`'s, since search results are Issue-shaped for both types.
- Added, beyond the bare acceptance criteria, ordinary CLI hygiene consistent with the sibling commands: a `search` group help, per-variant `--help` text, an unknown-subcommand `VALIDATION_ERROR`, a too-many-positionals rejection, and top-level-help entries in `cli.ts`.

View File

@@ -13,10 +13,31 @@ Issue fetching passes `type=issues`; outside a recognizable Gitea repo the dashb
## Acceptance criteria
- [ ] Bare `gitea-axi` renders the header, `repo:` line, up to 3 issues and 3 PRs with the specified fields (including the computed `review`), and a `help:` block hinting at `--full`
- [ ] `gitea-axi --full` renders the PR table capped at 20 rows with `count: 20 of T total` and issue counts grouped by label
- [ ] Label aggregation paginates to the 1000-issue cap, suffixes counts with `+` when capped, counts each issue under all its labels, and shows `unlabeled` only when nonzero
- [ ] Empty states render `prs: 0 open` / `issues: 0 open` as raw strings
- [ ] Issue fetches pass `type=issues` so PRs never appear in the issue block
- [ ] Outside a Gitea repo the dashboard exits with `REPO_NOT_FOUND` and help mentioning `-R` and `--login`
- [ ] Fixture-server tests cover both tiers, the cap-and-suffix behavior, empty states, and the no-repo error
- [x] Bare `gitea-axi` renders the header, `repo:` line, up to 3 issues and 3 PRs with the specified fields (including the computed `review`), and a `help:` block hinting at `--full`
- [x] `gitea-axi --full` renders the PR table capped at 20 rows with `count: 20 of T total` and issue counts grouped by label
- [x] Label aggregation paginates to the 1000-issue cap, suffixes counts with `+` when capped, counts each issue under all its labels, and shows `unlabeled` only when nonzero
- [x] Empty states render `prs: 0 open` / `issues: 0 open` as raw strings
- [x] Issue fetches pass `type=issues` so PRs never appear in the issue block
- [x] Outside a Gitea repo the dashboard exits with `REPO_NOT_FOUND` and help mentioning `-R` and `--login`
- [x] Fixture-server tests cover both tiers, the cap-and-suffix behavior, empty states, and the no-repo error
- [x] End-to-end tests render the bare dashboard and `--full` against a live Gitea instance, confirming the live issue/PR list response shapes and that the computed `review` and label-aggregation fields hold against real responses — behavior the fixture server cannot attest to
## Implementation Notes
The two tiers live in a new `src/commands/dashboard.ts` wired as the SDK's `home` handler.
The handler returns a **string** (not an object), so the SDK prepends the `bin:`/`description:` header verbatim above the dashboard's bespoke layout — the raw `prs: 0 open` / `issues: 0 open` empty-state lines and the label→count record cannot be expressed as a plain object for the SDK to encode.
`--full` is extracted in `runCli` before the SDK dispatches, and only when it is the sole remaining argument (after the global `-R`/`--login` flags are stripped).
This is because the SDK rejects any flag placed before a command, so `gitea-axi --full` would otherwise never reach the `home` handler; the extraction leaves an empty argv for the SDK and threads a `full` boolean into `dashboardCommand`.
`paginate.ts`'s `PaginatedResult` gained a `capped` flag: `fetchAllPages` now reports whether it stopped at the 20-page/1000-item cap with every page full, which drives the `+` suffix on the label counts.
The addition is backward-compatible — existing callers destructure only `items`/`total`.
Decisions made mid-implementation (all beyond the literal spec, kept deliberately):
- **Label-count ordering.** The spec fixes *what* to count but not the order; the `issues:` record is emitted by descending count, ties broken by name ascending, with `unlabeled` always last — a stable, at-a-glance ordering rather than an arbitrary one.
- **Defensive PR cap.** `fetchOpenPulls` slices the returned page to the requested limit so a server that ignored `limit=20` cannot inflate the table (or the review-fetch fan-out) past the cap.
- **Count line always present in the full tier**, including the empty case (`count: 0 of 0 total` above `prs: 0 open`), consistent with the list commands' count-line convention.
- **Top-level `--help`** gained a short note that the bare command shows the dashboard and `--full` selects the rich view, for discoverability.
The short and full tiers each re-slot the computed `review` after the PR fields via a local `prRowsWithReview` helper rather than sharing `pr list`'s inline loop — `pr list` also merges `--fields` extras into the same row, so the two are not the same operation despite the shared "review after the fields" shape (ADR 0006).

View File

@@ -11,11 +11,29 @@ Author the Agent Skill markdown as a minimal pointer, not a command reference: f
`setup hooks` installs a SessionStart hook via axi-sdk-js's `installSessionStartHooks()` for Claude Code, Codex, and OpenCode; the hook runs the bare binary (short dashboard tier) at session start.
`update` shadows the SDK's built-in self-update command, failing with `VALIDATION_ERROR` and a help line pointing at the npm update command, keeping the ten-code error list intact.
There is no postinstall script — skill and hook installation are always explicit user actions.
These commands touch only the local filesystem, not the Gitea API, so their real-integration surface is the CLI seam against a temporary HOME rather than the live-Gitea end-to-end tier — there is no live-Gitea behavior for an e2e test to attest to here.
## Acceptance criteria
- [ ] The Agent Skill markdown is bundled in the package and follows the minimal-pointer shape (trigger description, when-to-use, command-group one-liners, discovery pointers)
- [ ] `gitea-axi setup` installs the skill and outputs `setup: { skill, path, status }`; re-running reports `updated` or `unchanged` rather than failing
- [ ] `gitea-axi setup hooks` registers the SessionStart hook for all three integrations via the SDK and outputs the `hooks:` block with a restart help line; managed entries are updated in place on re-run
- [ ] `gitea-axi update` fails with `VALIDATION_ERROR` (exit 2) and the npm update help line; the SDK's `UPDATE_ERROR` never surfaces
- [ ] Tests cover the setup idempotency states and the update shadow (hook installation verified against a temp home directory)
- [x] The Agent Skill markdown is bundled in the package and follows the minimal-pointer shape (trigger description, when-to-use, command-group one-liners, discovery pointers)
- [x] `gitea-axi setup` installs the skill and outputs `setup: { skill, path, status }`; re-running reports `updated` or `unchanged` rather than failing
- [x] `gitea-axi setup hooks` registers the SessionStart hook for all three integrations via the SDK and outputs the `hooks:` block with a restart help line; managed entries are updated in place on re-run
- [x] `gitea-axi update` fails with `VALIDATION_ERROR` (exit 2) and the npm update help line; the SDK's `UPDATE_ERROR` never surfaces
- [x] Integration tests drive `setup`, `setup hooks`, and `update` at the CLI seam against a temporary HOME, asserting the real skill file and the three managed hook configs are written and updated in place, and covering the setup idempotency states and the update shadow (this temp-HOME filesystem tier is the applicable real-integration test; these commands make no Gitea API calls, so there is no live-Gitea e2e case)
## Implementation Notes
The bundled skill lives at `skills/gitea-axi/SKILL.md` and ships via a new `"skills"` entry in `package.json`'s `files`.
`src/commands/setup.ts` resolves both the skill source and the CLI entrypoint relative to `import.meta.url` (`../../skills/...` and `../main.js`), so they track the install tree regardless of how the process was launched; the dist layout mirrors `src/`, so the same relative paths resolve under both the built output and the source-run test tier.
`setup` reads `HOME` from the injected env (falling back to `USERPROFILE`, then `os.homedir()`), which is what lets the integration tests drive it against a temporary HOME through the ordinary CLI seam.
`setup hooks` calls the SDK's `installSessionStartHooks()` with an explicit `marker`/`binaryNames`/`execPath`/`homeDir` and `shouldInstall: () => true`.
The unconditional install is deliberate: the SDK's auto-install safety gate is tuned for an inferred `dist/bin/<name>.js` entrypoint, which gitea-axi does not use (its entrypoint is `dist/main.js`), so the default gate would refuse to install. `setup hooks` is an explicit user action, so gating it on the entrypoint layout is inappropriate.
Hook errors from the SDK are collected via `onError` and, if any occur, surfaced as a single thrown error rather than silently swallowed.
`update` is registered as a normal command in `cli.ts`, which shadows the SDK's reserved built-in (the SDK only runs its own `update` when the tool has not registered one); the handler always throws `VALIDATION_ERROR` with the npm-update help line, so the SDK's `UPDATE_ERROR` can never surface.
Deviations / follow-ups:
- **Process deviation:** the implementation was written before the tests this cycle, then tests were authored test-first-style by a sub-agent from the public interface only. The RED step therefore did not produce genuine failures (the code already existed); the tests were confirmed green instead. Assertions were derived from the spec/ADRs as independent literals, not from observed output.
- **Follow-up (out of scope here):** ADR 0009's consequences mention the dashboard suggestion table hinting at `setup` for discoverability. That hint is not among this task's acceptance criteria and would touch task 0017's `dashboard.ts`, so it is left as a follow-up; `setup` is currently discoverable via the top-level `--help`.

View File

@@ -9,10 +9,28 @@ Publish readiness for the unscoped `gitea-axi` npm package.
Package metadata (name, description, repository, license, engines for Node 20+, ESM), the `gitea-axi` bin entry, and the bundled Agent Skill file included in the published artifact.
No postinstall script — the install delivers the CLI binary only, and skill installation stays behind the explicit `setup` command.
Verify the packed artifact: a global install from the packed tarball yields a working binary whose `setup` finds the bundled skill.
This tarball-and-global-install check is the applicable real-integration surface for this slice; distribution touches no Gitea API, so there is no live-Gitea end-to-end case — the packaging smoke test below stands in its place.
## Acceptance criteria
- [ ] The packed tarball contains the built CLI, the bin entry, and the Agent Skill markdown, and nothing declares a postinstall script
- [ ] A global install from the tarball puts a working `gitea-axi` on the PATH (dashboard header, `--help`, and `setup` all function)
- [ ] Package metadata is complete: unscoped name, description, repository URL, license, Node 20+ engines, ESM module type
- [ ] The publish flow (registry target, access, prepack build) is documented or scripted so publishing is a single command
- [x] The packed tarball contains the built CLI, the bin entry, and the Agent Skill markdown, and nothing declares a postinstall script
- [x] A global install from the tarball puts a working `gitea-axi` on the PATH (dashboard header, `--help`, and `setup` all function)
- [x] Package metadata is complete: unscoped name, description, repository URL, license, Node 20+ engines, ESM module type
- [x] The publish flow (registry target, access, prepack build) is documented or scripted so publishing is a single command
## Implementation Notes
Most package metadata (unscoped name, description, `type: "module"`, MIT license, `engines.node >=20`, the `gitea-axi` bin, and the `files` allowlist shipping `dist` + `skills`) already existed from earlier tasks; this slice added the missing `repository` (plus conventional `homepage`/`bugs`) and the publish wiring.
`prepublishOnly` was replaced with `prepack: "npm run build"`.
`prepack` fires on both `npm pack` and `npm publish`, so the tarball always carries a freshly built `dist/` — which the packaging smoke test's `npm pack` relies on — whereas `prepublishOnly` only ran on publish.
`publishConfig` pins `access: "public"` (so the unscoped package publishes without `--access public`) and `registry: "https://registry.npmjs.org/"` (so a machine with a different default registry still publishes to the right place), making `npm publish` a genuine single command.
The flow is also written down in `PUBLISHING.md`.
The packaging smoke test is its own tier: `vitest.packaging.config.ts` + the `test:pack` script, excluded from the fast `npm test` run (it packs, installs globally, and fetches runtime deps from the registry, so it is slow).
Distribution touches no Gitea API, so — exactly as the task frames it — this tarball-and-global-install check stands in for the absent live-Gitea e2e tier rather than being one.
Deviations / follow-ups:
- **Process deviation (TDD sequencing):** the test-writer sub-agent ran concurrently with the `package.json`/`PUBLISHING.md` edits, so by its final run it observed GREEN and did not report a clean RED for the metadata/publish facets (it mis-attributed the fields to the prior commit). The pre-edit tree was genuinely RED for those facets (no `repository`, `prepack`, `publishConfig`, or `PUBLISHING.md`); the file-presence and installed-binary facets were already GREEN because the CLI, bin, and bundled skill shipped in task 0018.
- **Test robustness fixes the sub-agent made:** the `npm pack --json` output shape differs across npm majors (array vs. object), so the test tolerates both; and the dashboard facet uses a promisified `execFile` rather than `execFileSync` so the in-process fixture Gitea server can answer the CLI's HTTP calls (a synchronous spawn deadlocks the shared event loop).

View File

@@ -0,0 +1,48 @@
---
spec: gitea-axi
---
## What to build
A single ruling on whether `--fields body` truncates, applied uniformly everywhere the `body` extra field is offered.
The spec contradicts itself today.
Principle 3 says body text is truncated at **500 characters** "in all contexts (list and detail alike)", while the Command Surface describes the `body` extra field as "`body` (raw)" for both `issue list --fields` and `issue create --fields`.
`issue view` follows Principle 3 (it routes the body through `truncateBody`, which also applies `cleanBody`), but the two `--fields` paths follow the Command Surface and emit the body raw and untruncated — `ISSUE_CREATE_EXTRA_FIELDS` in task 0004, then `ISSUE_LIST_EXTRA_FIELDS` in task 0002, which copied the precedent rather than diverge from it.
The cost is real on the list path: `issue list --limit 30 --fields body` can emit 30 full issue bodies into an agent's context, which is the exact expense Principle 3 exists to prevent.
The plausible reading of "raw" is *uncleaned markdown* (no `cleanBody`) rather than *unbounded*, but that is a guess and the two passages need reconciling in the spec text, not in an extractor.
Decide, then make the spec and the code agree:
- If bodies truncate in `--fields` too, route the `body` extractor through `truncateBody` (a `truncatedBody()` FieldDef alongside `pluck`/`joined`/`relativeTimeField` in `fields.ts`), and amend the Command Surface's "(raw)" wording.
- If `--fields body` is genuinely exempt, amend Principle 3 to carve out the exemption explicitly and say why, so the next slice offering a `body` field does not have to re-derive it.
Whichever way it goes, `issue list`, `issue create`, and any later command exposing `body` via `--fields` must behave the same.
The ruling is verified at the fixture/unit tier only: body truncation is deterministic string processing over a body the CLI already holds, with no live-Gitea response semantics for an end-to-end test to attest to, so no e2e case is warranted here.
## Acceptance criteria
- [x] The spec no longer contradicts itself: Principle 3 and the Command Surface's `body` field agree, with the reasoning recorded
- [x] `issue list --fields body` and `issue create --fields body` behave identically under the ruling
- [x] If truncation wins, the truncation hint and `--full` affordance match how `issue view` already presents a truncated body (see ADR 0003)
- [x] Tests cover the ruled behaviour on both commands
## Implementation Notes
**The ruling: `--fields body` truncates.**
Principle 3 already declared truncation applies "in all contexts (list and detail alike)"; the four `--fields body` extractors were the drift, not the intent.
"Raw" in the Command Surface is resolved to mean *uncleaned markdown* (short bodies still pass through byte-for-byte), never *unbounded* — an unbounded body on a list path lets `issue list --limit 30 --fields body` spill thirty full bodies into an agent's context, the exact cost Principle 3 exists to prevent.
**Code.**
Added a `truncatedBody()` `FieldDef` in `fields.ts` (alongside `pluck`/`joined`/`relativeTimeField`) that routes the value through the same `truncateBody(value, BODY_TRUNCATE_LIMIT, host)` that `issue view` / `pr view` use, so the hint text and `cleanBody`-on-overflow behaviour are byte-identical.
`ExtractContext` gained `host` and `full` (both required) — the render context now declares its hostname and truncation mode explicitly; every `extractRow` call site was updated (the body-less ones — dashboard, label list, relationships — pass `full: false`).
The four registries (`ISSUE_LIST`, `ISSUE_CREATE`, `PR_LIST`, `SEARCH` extra fields) now use `truncatedBody("body")`.
**Scope beyond the two commands the ACs name.**
The task body mandated that "`issue list`, `issue create`, and any later command exposing `body` via `--fields` must behave the same", so `pr list` and `search` were included too — each got the ruling and a parity test.
A `--full` flag (suppresses the `--fields body` truncation, matching `issue view`) was added to `issue list`, `issue create`, `pr list`, and `search`, since the inline hint literally says "use --full to see complete body" and that promise must be keepable on every command that offers the field.
Help text for all four commands and ADR 0003's consequences were updated accordingly.
**Verification tier.**
Fixture/unit tier only, as the task specified — no e2e case: truncation is deterministic string processing over a body the CLI already holds, with no live-Gitea response semantics to attest to.

View File

@@ -0,0 +1,27 @@
---
spec: benchmark-harness
---
## What to build
The foundation the whole benchmark harness reads and writes: a `bench/` directory (excluded from the published npm package, alongside the existing `dist`/`skills` allow-list), the immutable result-record shape, and an append-only per-cell sample store.
A result record captures one completed `(arm, task, trial)` run: the four token components (fresh input, cache-creation, cache-read, output), the turn count, the wall-clock duration, the imputed cost, and the pass/fail outcome with a failure tag distinguishing a confused agent from a hung one. It also carries the tags later views group by — arm, task id, tier, trial, and a timestamp.
The store appends records as immutable, timestamped samples to a per-cell location. Deepening a cell's sample size adds samples rather than overwriting any prior run, and reading a cell returns every accumulated sample.
## Acceptance criteria
- [x] A `bench/` directory exists and is excluded from the npm package (verified by the packaging tier or an equivalent `files` check).
- [x] The result-record shape records the four token components, turns, duration, imputed cost, outcome, failure tag, and the arm/task/tier/trial/timestamp tags.
- [x] Appending a sample to a cell that already has samples leaves the prior samples intact; reading the cell returns all of them.
- [x] A round-trip test writes several samples across cells and reads back exactly what was written.
## Implementation Notes
- The harness lives in `bench/`, kept out of the published package by the existing `files` allow-list (`["dist", "skills"]`); a new assertion in the packaging tier (`test/packaging/packaging.test.ts`) locks in that `package/bench` never ships.
- `bench/result.ts` holds the immutable `ResultRecord` shape. `Arm` and `Tier` are typed unions (the four arms; the four task tiers from the spec) rather than bare strings, and the failure tag is `"incorrect" | "confused" | "hung"` — the spec/task only required distinguishing confused (turn cap) from hung (wall-clock backstop), and `incorrect` is added for the ordinary checker-scored-wrong failure the later runner will record.
- `bench/store.ts` is the append-only sample store, backed by one newline-delimited JSON file per cell at `<root>/<arm>/<taskId>.jsonl`. Immutability is structural: the store exposes only `append`/`read`/`cells`, and append is a bare file append, so deepening a cell can only add lines. `cells()` enumerates written cells — slightly beyond the literal criteria but foundational for the aggregator slice (0030), which reads the store.
- Bench tests run in a dedicated tier (`vitest.bench.config.ts`, `npm run test:bench`), colocated with the source, kept out of the fast tier so harness code never counts against the `src/` coverage thresholds. `tsconfig.json` now includes `bench` so the harness is typechecked.
- Benchmark vocabulary (arm, cell, tier, cost-equivalent tokens, seed, checker) is documented in `bench/README.md`, deliberately kept out of the tool's domain glossary (`.claude/CONTEXT.md`) per the spec's Further Notes.
- Review: Risk overall Low; Spec axis clean; two Standards judgement-call Duplicated Code findings addressed in the refactor — `append` now routes through `cellPath`, and the repeated ENOENT handling is factored into one `ignoreEnoent` helper.

View File

@@ -0,0 +1,31 @@
---
spec: benchmark-harness
---
## What to build
The guard that keeps each arm's agent confined to exactly one tool, so a benchmark result measures the tool rather than the agent's choice between tools (see the guard-based-tool-isolation ADR).
The guard is a callback that inspects every proposed shell command and permits only the one binary allow-listed for the active arm plus a curated set of harmless utilities, denying everything else. It rejects foreign binaries, absolute-path evasions that sidestep the allow-list, and interpreter-based fetch tricks (reaching the API through a language runtime's HTTP client). A curated per-arm PATH backs the guard as a convenience layer, but the guard — not the PATH — is authoritative. A blocked attempt is surfaced, not silently retried.
## Acceptance criteria
- [x] Each arm's allow-listed binary passes the guard; a foreign binary is denied.
- [x] An absolute-path invocation of a foreign binary is denied.
- [x] An interpreter-based fetch attempt (e.g. driving an HTTP request through a language runtime) is denied.
- [x] A curated per-arm PATH is produced exposing only that arm's allowed binary.
- [x] Unit tests cover the allowed-binary, foreign-binary, absolute-path, and interpreter-fetch cases per arm.
## Implementation Notes
The guard lives in `bench/guard.ts` and exposes a small interface over a deliberately deep implementation:
- `guardCommand(arm, command)` — the authoritative guard, returning `{ allowed: true }` or `{ allowed: false, reason }`.
- `provisionArmBin(arm, binDir, locate?)` — populates a per-arm bin directory with a single symlink to the arm's binary (empty for `gitea-mcp`); `locate` is an injectable resolver so tests stay host-independent.
- `ARM_BINARY` and `HARMLESS_BINARIES` — the per-arm allow-listed binary (`null` for the shell-disabled `gitea-mcp` arm) and the curated set of harmless read/text/flow utilities.
Depth added beyond the literal criteria, invited by ADR 0016 ("isolation strength rests on the completeness of the guard's deny rules"): the guard checks *every* binary a command would reach, not just the leading token, via a hand-rolled shell command parser (`extractCommands`) that handles pipelines, `;`/`&&`/`||` sequences, subshells, `$(...)` and backtick substitutions, process substitutions, redirections (including `2>&1` and `&>` forms), and leading `NAME=value` assignments. This closes pipe-hiding and substitution-hiding evasions in addition to the named absolute-path and interpreter-fetch cases. Path-qualified invocations are refused even for the arm's own binary, since the curated PATH is meant to resolve it by name and a path-qualified form is a symlink/copy evasion vector.
The `gitea-mcp` arm runs with the shell disabled entirely (no allow-listed binary), so `guardCommand` denies every shell command for it with a shell-disabled reason, and `provisionArmBin` exposes nothing.
Tests are colocated in `bench/guard.test.ts` (31 tests) and run via `npm run test:bench`, kept out of the `src/` coverage tier.

View File

@@ -0,0 +1,32 @@
---
spec: benchmark-harness
---
## What to build
The pure scoring seam that turns a completed run into a deterministic pass/fail, plus the scoring-spec contract each task pairs with.
For a mutation task, the checker diffs the entire post-run repository state against the expected end state, so both the intended change and any collateral damage are caught. Comparison runs after normalization: volatile identifiers and timestamps are dropped, comments are matched by author and body, and labels are compared as sets. For a read task, the checker matches required facts in the agent's final report against the seeded ground truth, with no LLM judge.
This slice also defines the scoring-spec contract — a task's expected end state (for mutations) or its required answer facts (for reads) — that the checker consumes and that the runner and task suite will produce. The checker is fed synthetic state snapshots and expected states; capturing live state from a real repository is the runner's job.
## Acceptance criteria
- [x] Given synthetic actual and expected state snapshots, the full-state diff passes when they match after normalization and fails when the actual state is missing the intended change.
- [x] The diff fails when the actual state carries collateral change beyond the intended mutation.
- [x] Normalization drops volatile identifiers and timestamps, matches comments by author and body, and compares label sets order-independently.
- [x] The read-task answer-match passes when the required facts are present in the final report and fails when a required fact is missing.
- [x] The scoring-spec contract expresses both a mutation's expected end state and a read's required answer facts.
## Implementation Notes
Two new files in `bench/`: `scoring-spec.ts` (the pure contract — `RepoState` and its `Label`/`Issue`/`PullRequest`/`Review`/`Comment` shapes, `RequiredFact`, and the `ScoringSpec` discriminated union) and `checker.ts` (the logic — `checkMutation`, `checkReadAnswer`, and a `score` entry point that dispatches on task kind). This mirrors the existing `result.ts` (shape) / `store.ts` (logic) split.
Decisions and deviations worth flagging:
- **Full-state diff covers the whole PR/issue surface, including reviews, assignees, and label definitions.** The criteria name only comments and label sets under normalization, but "diffing the entire post-run repository state" (User Story 5) and the contract's need to express the scored suite's review/merge/assignee tasks (criterion 5) make these part of the contract, not scope creep. They are groundwork the runner and task suite will populate.
- **Reviews are matched order-independently**, consistent with how comments and labels are compared (spec line 9). A code review caught that reviews were initially order-dependent; this was fixed and covered by a test (`passes when a pull request's reviews match as a set despite differing order`). Review inline comments are likewise matched by author and body, order-independently.
- **Failure diagnostics (`differences` naming the affected entity/label/comment/fact)** go beyond the bare pass/fail the criteria require, so a failed trial is traceable to what diverged (User Story 14 spirit). Heavily tested.
- **Read-answer matching is deterministic substring matching** (case- and whitespace-normalized, with `anyOf` alternatives per fact), no LLM judge. This is intentionally naive — e.g. `"#42"` would match inside `"#420"` — and is mitigated by the task suite choosing disambiguating `anyOf` renderings rather than by the checker. The required facts carried in the `ScoringSpec` *are* the seeded ground truth for read tasks.
- **`score` throws on a spec/submission kind mismatch** rather than silently scoring the wrong thing; this keeps the seam deterministic and is covered by a guard test.
- No criteria were dropped; all five are satisfied.

View File

@@ -0,0 +1,32 @@
---
spec: benchmark-harness
---
## What to build
The deterministic, idempotent seed that brings a freshly provisioned throwaway repository to a known ground truth before a trial runs, scripted entirely over the Gitea API against the live host (see the single-user-seed ADR). Authentication reuses gitea-axi's existing credential discovery path rather than introducing new secret handling.
The seed establishes a fixed set of labels with fixed colors; a spread of open and closed issues varying by label, assignee presence (assigned-to-self versus unassigned), title keyword, and pre-existing comments; and a handful of pull requests including one labeled, one carrying an existing review, and one backed by a real pushed feature branch. All content is authored by the single available user, so the discriminating dimensions are label, state, assignee presence, and title keyword — not author.
## Acceptance criteria
- [x] Provisioning a fresh repository and seeding it produces the fixed labels, the open/closed issue spread across the discriminating dimensions, and the pull requests (labeled, reviewed, and real-branch-backed).
- [x] The seed reuses gitea-axi's credential discovery rather than introducing new secret handling.
- [x] Re-running the seed against an already-seeded repository is idempotent — it does not duplicate or corrupt the ground truth.
- [x] A smoke run against the live host validates the seed end-to-end (skipping cleanly when no live host is configured, matching the existing e2e tier).
## Implementation Notes
The slice splits into two seams: `bench/seed-plan.ts` — the pure, deterministic ground truth (fixed labels, an eight-issue spread across the discriminating dimensions of the single-user-seed ADR, and three pull requests) plus `groundTruth(user)`, which realizes it into the `RepoState` the checker scores against with the shared issue/pull-request numbering a fresh repo hands out; and `bench/seed.ts` — the idempotent seeding scripted over the live Gitea API.
The pure seam was driven test-first (`bench/seed-plan.test.ts`); the imperative seam is validated by the live smoke run (`bench/seed.smoke.test.ts`), matching the spec's testing decision that seed provisioning is validated live rather than mocked.
Decisions and deviations:
- **Credential reuse.** `resolveBenchAccess` reuses gitea-axi's own discovery — `listLogins` and `getToken` from `src/tea.ts` and `selectLogin` from `src/context.ts`, which was widened from private to `export` for this. No new secret handling was introduced.
- **Smoke tier.** The live smoke test is its own vitest tier (`vitest.bench-smoke.config.ts`, `npm run test:bench:smoke`), gated on `GITEA_AXI_BENCH_LOGIN`, so the deterministic `test:bench` tier stays free of live network. Validated live against `git.alexion.dev`; the throwaway repo is deleted afterward.
- **`readSeedSummary`.** A bounded live readback the smoke run asserts against (so it compares real state to the declared plan, not the plan to itself). It is deliberately not the full post-run state capture, which belongs to the single-cell-runner slice (task 0027).
- **`deleteRepo`.** Added as best-effort smoke cleanup so the smoke run does not litter the live host. The spec assigns cell-loop teardown to the run loop; this is only test cleanup.
- **Pull-request state reconciliation.** Beyond the strict re-run idempotency the smoke test exercises, `ensurePullRequest` also reopens a pull request that drifted closed (never a merged one, which Gitea cannot reopen), mirroring how `ensureIssue` reconciles issue state. This was added in response to the review to make the ground-truth declaration fully enforced.
- **Self-review promotion.** ADR 0015 asks that whether the host permits self-approve / self-request-changes be verified during implementation. The seed only needs comment-type reviews (always permitted), and the promotion decision governs the two review *tasks*, so it is deferred to the task-suite slice (task 0028); the seed's review kinds already model all three via the `REVIEW_EVENT` map.
Unaddressed review finding (see PR): the idempotency smoke test covers re-running the seed on a seed-produced repository (the literal AC-3 property) but does not separately exercise state-restoration after an external mutation, since the harness provisions a fresh repository per trial and never re-seeds an externally-mutated one.

View File

@@ -0,0 +1,35 @@
---
spec: benchmark-harness
blocked-by: 0023-bench-tool-isolation-guard
---
## What to build
The per-arm scaffolding that charges each tool's real ambient-context cost honestly. All arms share one task-agnostic base prompt and the same repository coordinates and token; each arm then receives a minimal, symmetric bootstrap naming its tool and pointing at that tool's own native discovery affordance.
The deliberate asymmetries follow the shipped products: the gitea-axi arm loads the bundled Agent Skill, because the Skill is part of what ships and its token cost belongs to gitea-axi. The tea and raw-API arms receive a one-line pointer to their native discovery affordance. The gitea-mcp arm's dispatcher schemas load eagerly as its ambient cost, and that arm disables the shell tool entirely, attaching only the MCP tools. Each arm's assembled prompt plus tool/PATH configuration (from the guard) is produced as a single arm definition the runner consumes.
## Acceptance criteria
- [x] All arms share the identical base prompt and are handed the same repository coordinates and token.
- [x] The gitea-axi arm's assembled context carries the bundled Agent Skill.
- [x] The tea and raw-API arms each receive only a one-line native-discovery pointer.
- [x] The gitea-mcp arm loads its dispatcher schemas eagerly, has the shell tool disabled, and is attached only the MCP tools.
- [x] Each non-MCP arm's tool/PATH configuration comes from the guard and exposes only that arm's allowed binary.
## Implementation Notes
The scaffolding lives in `bench/arm.ts`, following the existing `bench/` seam pattern. Two exports:
- `basePrompt(context)` — the identical, task-agnostic base prompt, carrying only the facts shared by every arm (repository coordinates, host URL, token) and naming no tool or task, so it is byte-for-byte identical across arms and the per-arm bootstrap is the only difference.
- `buildArm(arm, context, options)` — assembles the single `ArmDefinition` the runner consumes: the fully assembled system prompt plus the tool configuration (`shell` xor `mcp`). The shell arms' `binDir`/PATH/guard come from `guard.ts` (`provisionArmBin` + `guardCommand`); the gitea-mcp arm gets `shell: null` and the MCP server attachment.
Decisions and deviations worth flagging:
- **AC4's "loads dispatcher schemas eagerly" and "attached only the MCP tools" are conveyed structurally, not enforced here.** Eager schema loading is inherent to attaching an MCP server — the Agent SDK lists the server's tools on connect — so the arm definition materializes it by carrying the `mcp` server config (with `shell: null`). Actually attaching *only* the MCP tools (granting no shell/other builtin tools) is the runner's job in task 0027; the arm definition expresses the intent via `shell: null` + a populated `mcp`. This split matches the spec, which places the SDK wiring in the runner slice.
- **`loadSkillBody` strips the skill's YAML frontmatter, embedding only the instructional body.** AC2 says the gitea-axi arm "carries the bundled Agent Skill"; the frontmatter's `description` is metadata Claude Code loads ambiently for *every* skill, so folding it into this one arm would double-count it and overcharge gitea-axi's ambient cost (User Story 4: "each tool's real ambient-context cost is charged honestly"). The body is what an active skill contributes.
- **The MCP env uses the official gitea-mcp server's own contract** (`GITEA_HOST`, `GITEA_ACCESS_TOKEN`, launched `-t stdio`), pointed at the shared host and token. The tests assert the env *values* (host + token), not the key names, to avoid coupling to launch details.
- **Path resolution matches the product's house style** (`new URL("../skills/gitea-axi/SKILL.md", import.meta.url)`, as in `src/commands/setup.ts`), rather than `import.meta.dirname`, per a review note; `bench/` runs from source so `import.meta.url` resolves to the shipped skill.
- **`skillPath` and `locate` options** are injectable seams (skill location; binary resolver) that keep the module host-independently testable; `locate` mirrors `provisionArmBin`'s existing parameter in `guard.ts`.
Review: Risk **Low**. No unaddressed Standards or Spec findings — the one actionable Standards note (house-style path resolution) was applied; the remaining review points are principled deviations documented above. No criteria dropped.

View File

@@ -0,0 +1,35 @@
---
spec: benchmark-harness
blocked-by: [0022-bench-scaffold-and-result-store, 0023-bench-tool-isolation-guard, 0024-bench-checker-and-scoring-spec, 0025-bench-seed-provisioning, 0026-bench-arm-scaffolding]
---
## What to build
The tracer bullet that threads every layer: run a single `(arm, task, trial)` cell end-to-end and record an immutable result. This is the walking skeleton — one arm against one sample task, one trial — that proves seed, arm scaffolding, guard, runner, checker, and store all connect.
The runner provisions and seeds a fresh throwaway repository, runs the agent via the Claude Agent SDK on a single fixed model at temperature zero with exactly the active arm's tool and guard enforced, and bounds the run by a turn cap and a wall-clock backstop — exceeding either records a failure tagged to distinguish a confused agent from a hung one. It captures the four token components (including the auxiliary small model the runtime invokes, since that is real consumption), the turn count, the duration, and the imputed cost. After the run it captures the entire post-run repository state as a snapshot, scores it with the checker against the task's scoring spec, appends the result sample to the store, and deletes the throwaway repository. A post-run transcript audit asserts no foreign tool was reached; a detected leak flags the trial invalid rather than letting it be scored.
This slice also defines the runnable Task wrapper (natural-language intent, parameters, tier, and scoring spec) and includes one sample task to exercise the path; the full suite is authored in a later slice.
## Acceptance criteria
- [x] Running one cell provisions and seeds a fresh repository, runs the agent under its arm's tool with the guard active, and deletes the repository afterward.
- [x] The run is bounded by both a turn cap and a wall-clock backstop; exceeding either records a failure tagged confused-versus-hung.
- [x] The recorded sample carries the four token components (including the auxiliary small model), turns, duration, imputed cost, and the checker's pass/fail outcome.
- [x] The post-run repository state is captured as a snapshot and scored by the checker against the task's scoring spec.
- [x] A transcript audit runs after each cell; a run in which a foreign tool was reached is flagged invalid instead of scored.
- [x] A runnable Task wrapper is defined and one sample task runs the full path against the live host.
## Implementation Notes
- **Two seams for the live boundaries.** The runner's orchestration (`bench/runner.ts`, `runCell`) is unit-tested with fakes by factoring the two non-deterministic boundaries behind interfaces: `BenchHost` (provision/seed/capture/delete against live Gitea, implemented by `liveBenchHost` in `bench/host.ts` over `seed.ts` + `snapshot.ts`) and `AgentDriver` (the model run, implemented by `sdkAgentDriver` in `bench/sdk-driver.ts` over the Claude Agent SDK). This mirrors the spec's split: run orchestration is the live boundary validated by the transcript audit and the smoke run, not by unit tests.
- **Claude Agent SDK is an optional peer.** The SDK (`@anthropic-ai/claude-agent-sdk`) is loaded via a computed dynamic import, so it is not a hard dependency of the (unshipped) `bench/` harness and neither the deterministic tier nor `npm run typecheck` requires it to be installed. The runner smoke tier (`bench/runner.smoke.test.ts`) skips cleanly when the SDK is absent or `GITEA_AXI_BENCH_LOGIN` is unset — a skip counts as a pass, matching the seed smoke tier. A real run additionally needs the `gitea-axi` CLI on `PATH` and a Claude subscription. Formalizing the SDK as a declared dependency belongs to the run-loop CLI slice (0029), which is the first consumer that runs it in earnest.
- **Temperature zero.** `sdkAgentDriver` passes `temperature: 0` in the SDK query options per the runner-and-metrics spec, so the comparison measures the tool rather than sampling noise. If a given SDK version does not surface a temperature knob, the field is additive and determinism falls back to the runtime default.
- **Shared isolation predicate.** Both isolation enforcement points share one `foreignToolReason(arm, use)` in `bench/audit.ts` so they cannot drift: the driver consults it in-band to deny a foreign tool before it runs, and the runner's `auditTranscript` re-applies it post-run as the independent backstop. Only tools that were permitted to run are recorded in the transcript, so a guard-blocked attempt is realistic wasted effort, not a leak — matching the spec's tool-isolation note that "blocked attempts are left in the transcript and count as realistic wasted effort."
- **Snapshot review verbs follow Gitea.** `bench/snapshot.ts` maps review states using Gitea's `ReviewStateType` verbs (`APPROVED` / `COMMENT` / `REQUEST_CHANGES`), which Gitea returns on read as well as on the write event (unlike GitHub's `CHANGES_REQUESTED`). Inline review comments are captured as empty, matching the single-user seed's declared ground truth, which never populates them. This capture is a live boundary exercised by the smoke tier rather than mocked.
- **No criteria dropped.** All six acceptance criteria are satisfied. The sample task is a single-mutation task (close a seeded issue), chosen so the "captured as a snapshot and scored by the checker" criterion is demonstrated through the full-state diff. The "runs the full path against the live host" criterion is realized by the runner smoke tier, which skips-as-pass when no host/SDK is configured, consistent with the project's e2e and seed tiers.

View File

@@ -0,0 +1,39 @@
---
spec: benchmark-harness
blocked-by: [0024-bench-checker-and-scoring-spec, 0025-bench-seed-provisioning, 0027-bench-single-cell-runner]
---
## What to build
The full scored task suite plus the capability-asymmetric bonus definitions, authored against the runnable Task wrapper and scored against the seed's ground truth.
The scored suite is 20 tasks drawn only from the capability surface shared by all four arms — issue and pull-request listing, viewing, creation, editing, closing and reopening, commenting and comment retrieval, label management and application, review comments, merge, and assignee changes. Tasks are phrased as natural-language intents, not command invocations, parametrized against the seed, each carrying its tier tag and scoring spec. The suite is weighted toward discovery and multi-step work: roughly four read tasks, six single-mutation tasks, six find-then-act tasks, and four multi-step workflows.
Review tasks default to comment-type reviews, which a single user can leave on their own pull request. Whether the host permits a user to approve or request changes on their own pull request is probed during implementation; if permitted, the two review tasks are promoted from comment reviews to approve and request-changes, otherwise those move to the bonus table.
The bonus definitions cover capability-asymmetric operations in both directions: where tea, gitea-mcp, or raw API fall short of gitea-axi (full-text search, diff, checks, checkout, issue dependencies), and operations outside gitea-axi's scope (repository, release, and milestone management), for which gitea-axi is reported not-applicable. These are kept out of the scored suite.
## Acceptance criteria
- [x] The scored suite has 20 tasks confined to the shared capability surface, phrased as natural-language intents parametrized against the seed.
- [x] The suite is weighted roughly four read / six single-mutation / six find-then-act / four multi-step, with each task carrying a tier tag and a scoring spec.
- [x] A self-review capability probe determines whether the two review tasks run as approve/request-changes or as comment reviews (falling back to the bonus table if self-review is not permitted).
- [x] Bonus task definitions cover the asymmetries in both directions, including the gitea-axi not-applicable operations, and are kept separate from the scored suite.
## Implementation Notes
The suite and bonus definitions live in `bench/task-suite.ts`; the self-review probe lives in `bench/self-review.ts`.
**Self-review probe as a runtime seam, not a baked-in constant.**
The task says self-review support is "probed during implementation." Rather than probing the host once and hard-coding a boolean, this splits the concern the way the rest of the harness is factored: `buildScoredSuite({ selfReviewPermitted })` and `buildBonusTasks({ selfReviewPermitted })` are pure functions unit-tested against a flag, and `self-review.ts` (`probeSelfReview` / `detectSelfReviewSupport`) is the live boundary that resolves the flag once per sweep — provision a throwaway repo, seed it, attempt an approval on the user's own pull request, delete the repo, report the verdict. This matches the seed/snapshot pattern (live boundaries are smoke-validated, not mocked) and means the suite tracks whatever the host actually permits instead of a guess frozen at authoring time. Wiring the probe into a full sweep belongs to the later run-loop-CLI slice; this slice ships the probe and the flag-driven builders.
**Review tasks placed in the find-then-act tier.**
The two review tasks (`fta-review-csv-pull`, `fta-review-docs-pull`) are find-then-act rather than single-mutation: each names its target pull request by a property (implements CSV export / refreshes documentation) and forces discovery before acting, which is the tier's defining trait. Their `kind` (and the intent's verb) toggles on `selfReviewPermitted`: approved/request-changes when permitted, comment otherwise; when not permitted the approve/request-changes operations are emitted as `self-review-unavailable` bonus entries instead.
**`bench/seed.ts` change.**
`request` (the non-throwing round-trip) is now exported so the probe can read a 4xx (a host forbidding self-approval) as `false` without it being thrown, while a network-level failure still propagates. This is the only edit outside the new files.
**Label creation in a multi-step task.**
`ms-create-and-apply-stale` creates a new label, which reads "label management" (shared-surface item) at its most generous. It is deliberately kept a scored task rather than a bonus one, since label creation is within every arm's reach.
All 20 mutation/read specs were cross-checked against the `SEED_PLAN` ground truth (target titles exist, pre-states and encoded changes match the intents) during the spec-fidelity review. No acceptance criteria were dropped.

View File

@@ -0,0 +1,44 @@
---
spec: benchmark-harness
blocked-by: [0027-bench-single-cell-runner, 0028-bench-task-suite]
---
## What to build
The maintainer-facing command that runs a chosen benchmark cell on demand, so only the token budget available at that moment is spent. The maintainer selects an arm and a task; the command runs that cell and accumulates results.
Each cell defaults to five trials with a reporting floor of three. Because results are immutable timestamped samples, running a cell that already has samples deepens it — the new trials append rather than overwrite, so a cell's sample size can be grown opportunistically across separate sittings.
## Acceptance criteria
- [x] The command runs a single selected `(arm, task)` cell on demand.
- [x] A cell defaults to five trials, and the reporting floor of three is respected.
- [x] Re-running an already-sampled cell appends new trials rather than overwriting prior samples.
- [x] The command drives the runner and store built in earlier slices rather than reimplementing orchestration.
## Implementation Notes
**Two seams, matching the harness's established split.**
The pure orchestration is `runCells` in `bench/run-loop.ts`: it decides only how many trials to run and at what trial numbers, then delegates provision/run/score/append to `runCell` and the sample store — it reimplements no orchestration (criterion 4).
The maintainer command is `bench/run.ts`: `parseRunArgs` is the pure, unit-tested argument seam, and `runBenchCommand`/`main` are the live boundary (resolve credentials, probe self-review, select the task, drive `runCells`).
Following `bench/README.md`'s convention, the live boundary is validated by running it rather than by mocked unit tests — and, deliberately, by reusing pieces already smoke-covered (`runCell` via the runner smoke, `detectSelfReviewSupport` via the self-review smoke, `liveBenchHost` via the seed smoke) rather than adding a new smoke test that would spend real tokens on every invocation.
**Deepening by highest trial number, not sample count.**
`runCells` continues numbering from `max(existing trial) + 1`, not from the sample count, so an earlier invalid attempt (which records no sample and leaves a gap) can never cause a later sitting to reuse a trial number.
**TDD sequencing deviation.**
The run loop is a small cohesive unit, so its first GREEN already carried the trial-numbering and floor logic; tests 2 and 3 (deepening, invalid/floor) and the parser override/reject/help tests are therefore passing characterization/regression tests rather than red-first cycles.
Each was still written test-first by the test-writer sub-agent, from the public interface only, with independent expected literals — they remain discriminating guards.
**Running the harness's TypeScript.**
Node's native type stripping does not rewrite `.js` import specifiers to `.ts`, which the whole `bench/` tree relies on, so the command runs under `tsx` (added as a devDependency) via `npm run bench:run`.
The Claude Agent SDK is now formally declared — as an *optional* `peerDependency` (`@anthropic-ai/claude-agent-sdk`) — so it is documented but neither installed for package consumers nor pulled into CI's `npm ci`; the maintainer installs it for live runs, matching how the driver already treats it as an optional peer.
The default store root `bench/results/` is gitignored.
**Review finding addressed — `--model` dropped.**
The spec fixes a single model across all arms so the comparison measures the tool, not the model.
A per-cell `--model` override (flagged as scope creep by the spec-fidelity review) would let arms drift onto different models, so it was removed.
`--turn-cap` and `--wall-clock-ms` were kept: they are safe-defaulted bounds a maintainer may legitimately need to raise for a heavier task, and they do not affect cross-arm comparability.
**Deferred to the aggregator (slice 0030).**
The CLI tally reports recorded/invalid counts and reporting-floor status, but does not break failures out by tag (incorrect/confused/hung); those tags are on every stored sample and are the reporting slice's job to surface.

View File

@@ -0,0 +1,30 @@
---
spec: benchmark-harness
blocked-by: [0022-bench-scaffold-and-result-store, 0028-bench-task-suite]
---
## What to build
The aggregator that renders the accumulated sample store into a readable comparison, rendering whatever exists and annotating incomplete coverage rather than blocking on a complete matrix.
The headline table has one row per arm: cost-equivalent tokens as the headline, then raw tokens, turns, duration, success rate, and a coverage figure, with imputed cost shown as a de-emphasized secondary column. Cost-equivalent tokens are computed at render time by weighting each run's four retained components by Anthropic's published API pricing ratios (see the cost-equivalent-token-metric ADR), so the stored records can be re-weighted without re-running if the subscription's accounting is ever documented. Partially-run cells are annotated rather than hidden.
Supporting views derived from the same records include a per-tier breakdown, a per-token-component breakdown, and the separate bonus table for the capability-asymmetric operations. The aggregator is a pure seam, unit-tested against synthetic sample stores.
## Acceptance criteria
- [x] The headline table renders one row per arm with cost-equivalent tokens as the headline, plus raw tokens, turns, duration, success rate, coverage, and imputed cost as a de-emphasized secondary column.
- [x] Cost-equivalent tokens are computed from the retained four components at render time using the documented pricing-ratio weights.
- [x] A partial matrix renders without error and incomplete coverage is annotated rather than hidden or treated as complete.
- [x] Per-tier and per-token-component breakdowns and the separate bonus table are rendered from the same records.
- [x] Rendering is stable and unit-tested against a synthetic append-only sample store.
## Implementation Notes
- The aggregator lives in `bench/aggregate.ts` as a pure seam: `aggregate` rolls a flat record list up against the task definitions into a `Report` (headline, per-tier and per-token-component breakdowns, and the bonus table), and `renderReport` renders that report as stable text. `readAllSamples(store)` drains a `SampleStore` into the record list the aggregator consumes, so the whole pipeline is `renderReport(aggregate({ records: readAllSamples(store), suite, bonus }))`.
- Cost-equivalent tokens are computed at render time from `COST_EQUIVALENT_WEIGHTS` (fresh input 1×, cache-write 1.25×, cache-read 0.1×, output 5×, per ADR 0014). The 1.25× cache-write weight is the 5-minute-TTL multiplier: the stored record retains a single un-TTL'd `cacheCreation` component, so the default write price applies. Documented at the constant.
- `aggregate` takes the suite as `TaskCoverage = Pick<BenchTask, "id" | "tier">` rather than the full `BenchTask`, since the aggregator only needs each task's id (to key cells) and tier (to group), never its scoring function. The real scored suite satisfies this, and tests can pass bare `{ id, tier }` lists. Coverage is scored against the full suite per arm, so a cell is `covered` at or above the reporting floor, `partial` below it, and `missing` at zero — the three always sum to the task count, which is what keeps a half-run matrix from reading as complete.
- Metrics are per-run means over an arm's samples; every mean is `number | null`, with `null` (rendered as an em dash) for an arm or cell with no samples, so an unrun arm is never shown as a zero. Success rate is the passing fraction.
- The bonus table renders one row per bonus definition carrying its capability metadata (operation, direction, note, and gitea-axi's own applicability), plus per-arm run metrics only for arms that actually have samples for that bonus cell — usually none, since the runner drives only the scored suite. This is the honest "from the same records" reading without inventing per-arm applicability the `BonusTask` model does not carry.
- Scope: this task delivers the aggregator seam only. No CLI wrapper was built — `bench/` slices split their command wiring into their own tasks (task 0029 was the dedicated run-loop CLI), so a `bench:report` command over this seam is flagged as a natural follow-up in `bench/README.md` rather than folded in here. All five acceptance criteria are about the pure aggregator, which is fully implemented and unit-tested (9 tests, including an append-order-stability test driving two real `createSampleStore` instances).
- Review (three-axis, `/review-uncommitted`): Risk overall Low. Spec axis clean — faithful and complete, weights correct per ADR 0014, coverage annotated not hidden. Standards axis found no hard violations, only judgement-call smells (Duplicated Code / Data Clumps / Repeated Switches across the four aggregation passes); left as deliberate trade-offs — the reviewer noted the flat per-pass form is readable and the view interfaces genuinely diverge, and the shared cost-equivalent-mean was already factored into `meanCostEquivalent`.

View File

@@ -0,0 +1,34 @@
---
spec: benchmark-harness
blocked-by: [0030-bench-aggregator-and-reporting]
---
## What to build
The maintainer-facing command that renders the accumulated sample store as the readable comparison, so the aggregator seam built in slice 0030 has a runnable entry point instead of being callable only from code.
The command opens the store, drains it, aggregates against the scored suite and bonus definitions, and prints the report — `renderReport(aggregate({ records: readAllSamples(store), suite, bonus }))` — to stdout.
It renders whatever has accumulated so far, annotating incomplete coverage rather than blocking on a complete matrix, exactly as the aggregator already does; the command adds only the argument seam and the live store/stdout boundary.
It is the reporting counterpart to `bench:run` (slice 0029): `bench/report.ts` stands over `bench/aggregate.ts` in the same shape as `bench/run.ts` stands over `bench/run-loop.ts`.
## Acceptance criteria
- [x] A `bench:report` command reads the accumulated store and prints the rendered report to stdout.
- [x] The store root defaults to `DEFAULT_STORE_ROOT` (`bench/results`) and is overridable with `--store`, matching `bench:run`.
- [x] The report is produced solely by driving the `readAllSamples` / `aggregate` / `renderReport` seam and the scored suite / bonus definitions from earlier slices — no aggregation, weighting, or rendering is reimplemented in the command.
- [x] A partial or empty store renders without error (incomplete coverage annotated, an unrun arm shown as an em dash), inheriting the aggregator's behaviour rather than special-casing it.
- [x] The argument parser is a pure, unit-tested seam (`--store`, `--help`, plus the `--self-review` / `--no-self-review` variant selector — see Implementation Notes), matching the `parseRunArgs` convention. Because the report boundary is offline, the whole command — not just the parser — is deterministic and unit-tested, rather than validated only by running it.
## Implementation Notes
- **Shape mirrors `bench/run.ts`.** `parseReportArgs` is the pure argument seam; `runReportCommand(argv, deps, out)` opens `createSampleStore(storeRoot)`, drives `renderReport(aggregate({ records: readAllSamples(store), suite, bonus }))`, and prints line-by-line through `out`; `main` and the `import.meta`/`argv[1]` direct-execution guard match `run.ts`. Added the `bench:report` script (`tsx bench/report.ts`) and replaced the flagged follow-up note in `bench/README.md` (plus a new "Reading the results" section).
- **The boundary is offline, so the whole command is unit-tested (deviation from the criterion's framing).** Criterion 5 as written assumed the run-command pattern where the live boundary is "validated by running it." But the report reads only the local sample store — no credentials, host, or Agent SDK — so `runReportCommand` is deterministic and is covered by real unit tests over a temp-dir store (populated, empty, and `--help`), not just the parser. It was still verified by running `npm run bench:report` end-to-end. The unused `deps` parameter is retained only for signature parity with the command family (commented at the parameter).
- **Added `--self-review` / `--no-self-review` — beyond the written `(--store, --help)` seam, deliberately.** `aggregate` needs a `suite` and `bonus`, and `buildScoredSuite`/`buildBonusTasks` are parameterized by `selfReviewPermitted`: when self-review is unavailable the two review tasks render as comment reviews in the scored suite and the approve/request-changes pair moves into the bonus catalog. `bench:run` resolves this by probing the live host (`detectSelfReviewSupport`); the offline report has no host to probe, so it cannot detect it and would otherwise have to hardcode one variant silently. The flag makes the choice explicit, defaulting to `true` (matching the richer self-review-permitted configuration). It is well-scoped: the scored coverage is identical either way, so it only selects the bonus capability catalog — documented in `--help` and the README. Criterion 5's "`--store`, `--help`" wording was updated to record it.
- **`DEFAULT_STORE_ROOT` moved to `store.ts`.** It was defined in `run.ts`; its natural home is the store, and both commands now need it, so it lives in `store.ts` as the single source of truth and is re-exported from `run.ts` to keep that command's existing importers (and `run.test.ts`) resolving it unchanged.
- **Tests, TDD.** `bench/report.test.ts` (8 tests) was authored test-first by the test-writer sub-agent from the public interface alone: the parser defaults/overrides/rejection/help, and the command over populated/empty/help paths. As with the `run-loop` slice, the parser is a small cohesive unit whose logic all landed in one GREEN, so the parser cases past the first are passing characterization guards rather than red-first cycles; the `runReportCommand` cell had a genuine red first. Full bench tier green (104 tests), typecheck clean.
- **Review (three-axis, `/review-uncommitted`): Risk overall Low.** Spec axis: all five criteria met; the only finding was that `--self-review` exceeded the written seam — justified in substance, now recorded here and in the criterion. Standards axis: no hard violations; two judgement calls — the `parseReportArgs` flag loop partly overlaps `parseRunArgs` but has genuinely diverged (adds `--no-` negation), left unextracted as the reviewer advised (a shared parser would be premature); and the unused `deps` parameter, addressed with a parity comment.

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

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
node_modules/
dist/
coverage/
bench/results/
result
result-*

View File

@@ -3,3 +3,86 @@
## Commits
Any commit message you write must follow the Conventional Commits specification as documented in [CONVENTIONAL-COMMITS.md](CONVENTIONAL-COMMITS.md).
## Gotchas
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 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.

46
PUBLISHING.md Normal file
View File

@@ -0,0 +1,46 @@
# Publishing gitea-axi
`gitea-axi` is an unscoped, public npm package.
Publishing it is a single command.
## Release
```sh
npm publish
```
That is the whole flow.
Everything the release needs is wired into `package.json`, so no extra flags are required:
- The `prepack` script runs `npm run build`, so the tarball always carries a freshly compiled `dist/` rather than whatever happened to be on disk.
- `publishConfig.access` is `public`, so the unscoped package publishes publicly without `--access public`.
- `publishConfig.registry` targets the public npm registry (`https://registry.npmjs.org/`), so a machine whose default registry is set elsewhere still publishes to the right place.
- The `files` allowlist ships only `dist/` and `skills/`, so the built CLI and the bundled Agent Skill go out and nothing else does.
There is deliberately no `postinstall` script.
Installing the package delivers the `gitea-axi` binary only; installing the Agent Skill and the session hooks stays an explicit user action behind `gitea-axi setup` and `gitea-axi setup hooks`.
## Before publishing
You need to be authenticated to the npm registry (`npm whoami` to check, `npm login` if not) with publish rights to the `gitea-axi` name.
Bump the version first with `npm version <patch|minor|major>`, which updates `package.json` and creates the release commit and tag.
## Verifying the packed artifact
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
```
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.

39
bench/README.md Normal file
View File

@@ -0,0 +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.
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 | 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 — and success is near-perfect: only `tea` slips, to 85% overall (67% on find-then-act), while the other three pass every run.
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.
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._

553
bench/aggregate.test.ts Normal file
View File

@@ -0,0 +1,553 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
aggregate,
costEquivalentTokens,
readAllSamples,
renderReport,
} from "./aggregate.js";
import type { ResultRecord, TokenComponents } from "./result.js";
import { createSampleStore } from "./store.js";
/** A minimal ResultRecord for the gitea-axi arm, overridable per sample. */
function record(overrides: Partial<ResultRecord> = {}): ResultRecord {
return {
arm: "gitea-axi",
taskId: "t1",
tier: "read",
trial: 1,
timestamp: "2026-07-16T00:00:00Z",
tokens: { freshInput: 0, cacheCreation: 0, cacheRead: 0, output: 0 },
turns: 0,
durationMs: 0,
imputedCostUsd: 0,
outcome: { pass: true },
...overrides,
};
}
describe("costEquivalentTokens", () => {
// Behavior: cost-equivalent tokens weight a run's four retained token
// components by Anthropic's published API pricing ratios — fresh input 1x,
// cache-write 1.25x, cache-read 0.1x, output 5x (ADR 0014). The expected
// total is derived BY HAND from those ratios, not recomputed the way the
// code would, so it independently pins the metric.
//
// Distinct components are chosen so each of the four weighted products is a
// different number and no two raw components share a value; a wrong weight on
// any single component therefore cannot be masked by another:
// freshInput 400 * 1 = 400
// cacheCreation 800 * 1.25 = 1000
// cacheRead 2000 * 0.1 = 200
// output 600 * 5 = 3000
// total = 4600
it("weights the four retained token components by the documented pricing ratios", () => {
const tokens: TokenComponents = {
freshInput: 400,
cacheCreation: 800,
cacheRead: 2000,
output: 600,
};
expect(costEquivalentTokens(tokens)).toBe(4600);
});
});
describe("aggregate", () => {
// Behavior: the headline view yields one row per arm whose per-run metrics —
// cost-equivalent tokens (the headline), raw tokens, turns, duration, and
// imputed cost — are the MEANS across that arm's samples, and whose success
// rate is the fraction of its runs that passed. Here the gitea-axi arm has
// two samples on task t1 (one pass, one fail). Every expected number below is
// derived BY HAND from the two samples and the ADR 0014 pricing ratios
// (fresh input 1x, cache-write 1.25x, cache-read 0.1x, output 5x), so it
// pins the metric independently of how aggregate computes it:
// cost-equiv A = 100*1 + 20*5 = 200; B = 200*1 + 40*5 = 400; mean = 300
// raw tokens A = 120; B = 240; mean = 180
// turns (5 + 7) / 2 = 6
// durationMs (1000 + 3000) / 2 = 2000
// successRate 1 pass of 2 = 0.5
// imputedCost (0.02 + 0.06) / 2 = 0.04
it("yields one headline row per arm with per-run means and the passing fraction as success rate", () => {
const sampleA = record({
trial: 1,
tokens: { freshInput: 100, cacheCreation: 0, cacheRead: 0, output: 20 },
turns: 5,
durationMs: 1000,
imputedCostUsd: 0.02,
outcome: { pass: true },
});
const sampleB = record({
trial: 2,
tokens: { freshInput: 200, cacheCreation: 0, cacheRead: 0, output: 40 },
turns: 7,
durationMs: 3000,
imputedCostUsd: 0.06,
outcome: { pass: false, failure: "incorrect" },
});
const report = aggregate({
records: [sampleA, sampleB],
suite: [{ id: "t1", tier: "read" }],
bonus: [],
});
const row = report.headline.find((r) => r.arm === "gitea-axi");
if (!row) throw new Error("expected a gitea-axi headline row");
expect(row.samples).toBe(2);
expect(row.costEquivalentTokens).toBeCloseTo(300);
expect(row.rawTokens).toBeCloseTo(180);
expect(row.turns).toBeCloseTo(6);
expect(row.durationMs).toBeCloseTo(2000);
expect(row.successRate).toBeCloseTo(0.5);
expect(row.imputedCostUsd).toBeCloseTo(0.04);
});
// Behavior: the per-tier breakdown is rendered from the same records — grouped
// by tier, then by arm — reporting each tier-arm's cost-equivalent-token mean
// and passing fraction. Three gitea-axi samples span two tiers: two on a read
// task r1 (one pass, one fail) and one on a single-mutation task m1 (pass).
// Every expected number is derived BY HAND from those samples and the ADR 0014
// weights (fresh input 1x, output 5x; no cache here), independent of how
// aggregate computes them:
// read gitea-axi: cost A = 100 + 20*5 = 200; B = 200 + 40*5 = 400;
// mean = 300; success 1 of 2 = 0.5; samples 2
// single-mutation gitea-axi: cost = 50 + 10*5 = 100; success 1 of 1 = 1;
// samples 1
// The tier order is the fixed reporting order, an independent literal.
it("breaks records down per tier and per arm with cost-equivalent-token means and success rates", () => {
const r1a = record({
taskId: "r1",
tier: "read",
trial: 1,
tokens: { freshInput: 100, cacheCreation: 0, cacheRead: 0, output: 20 },
outcome: { pass: true },
});
const r1b = record({
taskId: "r1",
tier: "read",
trial: 2,
tokens: { freshInput: 200, cacheCreation: 0, cacheRead: 0, output: 40 },
outcome: { pass: false, failure: "incorrect" },
});
const m1c = record({
taskId: "m1",
tier: "single-mutation",
trial: 1,
tokens: { freshInput: 50, cacheCreation: 0, cacheRead: 0, output: 10 },
outcome: { pass: true },
});
const report = aggregate({
records: [r1a, r1b, m1c],
suite: [
{ id: "r1", tier: "read" },
{ id: "m1", tier: "single-mutation" },
],
bonus: [],
});
// One breakdown per tier, in the fixed reporting order.
expect(report.tiers.map((t) => t.tier)).toEqual([
"read",
"single-mutation",
"find-then-act",
"multi-step",
]);
const readAxi = report.tiers
.find((t) => t.tier === "read")
?.arms.find((a) => a.arm === "gitea-axi");
if (!readAxi) throw new Error("expected a read/gitea-axi tier-arm");
expect(readAxi.samples).toBe(2);
expect(readAxi.costEquivalentTokens).toBeCloseTo(300);
expect(readAxi.successRate).toBeCloseTo(0.5);
const mutAxi = report.tiers
.find((t) => t.tier === "single-mutation")
?.arms.find((a) => a.arm === "gitea-axi");
if (!mutAxi) throw new Error("expected a single-mutation/gitea-axi tier-arm");
expect(mutAxi.samples).toBe(1);
expect(mutAxi.costEquivalentTokens).toBeCloseTo(100);
expect(mutAxi.successRate).toBeCloseTo(1);
});
// Behavior: the per-token-component breakdown is rendered from the same
// records — per arm, the per-run MEAN of each of the four raw token
// components — so a reader can see what drives an arm's cost. The gitea-axi
// arm has two samples; an arm with no samples reports null for every
// component. Each mean is derived BY HAND from the two samples, independent
// of how aggregate computes it:
// freshInput (100 + 200) / 2 = 150
// cacheCreation (40 + 60) / 2 = 50
// cacheRead (1000 + 3000)/2 = 2000
// output (20 + 40) / 2 = 30
it("breaks records down per arm into the per-run mean of each token component", () => {
const sampleA = record({
trial: 1,
tokens: { freshInput: 100, cacheCreation: 40, cacheRead: 1000, output: 20 },
});
const sampleB = record({
trial: 2,
tokens: { freshInput: 200, cacheCreation: 60, cacheRead: 3000, output: 40 },
});
const report = aggregate({
records: [sampleA, sampleB],
suite: [{ id: "t1", tier: "read" }],
bonus: [],
});
const axi = report.components.find((c) => c.arm === "gitea-axi");
if (!axi) throw new Error("expected a gitea-axi component breakdown");
expect(axi.freshInput).toBeCloseTo(150);
expect(axi.cacheCreation).toBeCloseTo(50);
expect(axi.cacheRead).toBeCloseTo(2000);
expect(axi.output).toBeCloseTo(30);
// An arm with no samples reports null for every component.
const tea = report.components.find((c) => c.arm === "tea");
if (!tea) throw new Error("expected a tea component breakdown");
expect(tea.freshInput).toBeNull();
expect(tea.cacheCreation).toBeNull();
expect(tea.cacheRead).toBeNull();
expect(tea.output).toBeNull();
});
// Behavior: the separate bonus table is rendered from the same records. It
// emits one row per supplied bonus definition, in the given order, carrying
// that operation's capability metadata (operation text, direction, note, and
// gitea-axi's own applicability), plus — for any arm that actually has
// samples for that bonus cell — the per-arm run metrics. Bonus cells are
// usually unrun, so a cell's arms list is empty unless records exist for it.
// Here bonus-x has one tea sample and bonus-y has none. The one derived
// number is by hand from that sample and the ADR 0014 weights (fresh input
// 1x, output 5x): cost = 100 + 20*5 = 200.
it("emits one bonus row per definition with its capability metadata and per-arm samples only where run", () => {
const teaSample = record({
arm: "tea",
taskId: "bonus-x",
tier: "read",
trial: 1,
tokens: { freshInput: 100, cacheCreation: 0, cacheRead: 0, output: 20 },
outcome: { pass: true },
});
const report = aggregate({
records: [teaSample],
suite: [],
bonus: [
{
id: "bonus-x",
operation: "Do the X thing",
direction: "gitea-axi-advantage",
note: "only gitea-axi does X ergonomically",
giteaAxi: "applicable",
},
{
id: "bonus-y",
operation: "Do the Y thing",
direction: "gitea-axi-not-applicable",
note: "Y is outside gitea-axi's surface",
giteaAxi: "not-applicable",
},
],
});
// One row per definition, in the given order.
expect(report.bonus.map((b) => b.id)).toEqual(["bonus-x", "bonus-y"]);
// bonus-x carries its capability metadata and the tea cell's metrics.
const bx = report.bonus.find((b) => b.id === "bonus-x");
if (!bx) throw new Error("expected a bonus-x row");
expect(bx.operation).toBe("Do the X thing");
expect(bx.direction).toBe("gitea-axi-advantage");
expect(bx.note).toBe("only gitea-axi does X ergonomically");
expect(bx.giteaAxi).toBe("applicable");
const bxTea = bx.arms.find((a) => a.arm === "tea");
if (!bxTea) throw new Error("expected a tea entry on bonus-x");
expect(bx.arms).toHaveLength(1);
expect(bxTea.samples).toBe(1);
expect(bxTea.costEquivalentTokens).toBeCloseTo(200);
expect(bxTea.successRate).toBeCloseTo(1);
// bonus-y was never run, so it carries metadata but no arm metrics.
const by = report.bonus.find((b) => b.id === "bonus-y");
if (!by) throw new Error("expected a bonus-y row");
expect(by.giteaAxi).toBe("not-applicable");
expect(by.direction).toBe("gitea-axi-not-applicable");
expect(by.arms).toEqual([]);
});
});
describe("renderReport", () => {
// Behavior: the rendered headline table shows one row per arm, presents
// cost-equivalent tokens as the headline metric column and imputed cost as a
// de-emphasized secondary column, and marks an arm with no samples with the
// "—" placeholder. Two gitea-axi samples on t1 give a cost-equivalent mean of
// (200 + 400)/2 = 300 and an imputed-cost mean of (0.02 + 0.06)/2 = 0.04,
// both derived BY HAND, independent of how the report is rendered. Assertions
// target the gitea-axi row's own line and the presence of labels/values —
// never fixed column widths — so they survive a layout refactor.
it("renders one row per arm with cost-equivalent tokens as the headline and imputed cost as a secondary column", () => {
const sampleA = record({
trial: 1,
tokens: { freshInput: 100, cacheCreation: 0, cacheRead: 0, output: 20 },
turns: 5,
durationMs: 1000,
imputedCostUsd: 0.02,
outcome: { pass: true },
});
const sampleB = record({
trial: 2,
tokens: { freshInput: 200, cacheCreation: 0, cacheRead: 0, output: 40 },
turns: 7,
durationMs: 3000,
imputedCostUsd: 0.06,
outcome: { pass: false, failure: "incorrect" },
});
const output = renderReport(
aggregate({
records: [sampleA, sampleB],
suite: [{ id: "t1", tier: "read" }],
bonus: [],
}),
);
// Every arm gets a row.
expect(output).toContain("gitea-axi");
expect(output).toContain("tea");
expect(output).toContain("gitea-mcp");
expect(output).toContain("raw-api");
// The headline metric and the secondary column are labelled.
expect(output.toLowerCase()).toContain("cost-equivalent");
expect(output.toLowerCase()).toContain("imputed");
// The gitea-axi row carries its cost-equivalent mean (300) and imputed
// mean (0.04) on its own line.
const axiLine = output.split("\n").find((l) => l.includes("gitea-axi"));
if (!axiLine) throw new Error("expected a gitea-axi row line");
expect(axiLine).toContain("300");
expect(axiLine).toContain("0.04");
// An arm with no samples shows the em-dash placeholder on its row. The
// "tea" arm token is matched at a word boundary so the substring inside
// "gitea-axi"/"gitea-mcp" cannot be mistaken for the tea row.
const teaLine = output
.split("\n")
.find((l) => /(^|[^a-z-])tea([^a-z-]|$)/.test(l));
if (!teaLine) throw new Error("expected a tea row line");
expect(teaLine).toContain("—");
});
// Behavior: a partial matrix renders without error and incomplete coverage is
// annotated rather than hidden or treated as complete. With the default
// reporting floor of 3, a cell is covered at >=3 samples, partial at 1-2, and
// missing at 0. gitea-axi has one covered / one partial / one missing cell,
// so it is incomplete; tea has all three cells covered, so it is complete.
// The coverage counts (1 partial, 1 missing for gitea-axi) are derived BY
// HAND from the sample layout, independent of how the report is rendered.
// Lines are located by content, not index, so the assertions survive a
// layout refactor.
it("renders a partial matrix without throwing and annotates incomplete coverage rather than hiding it", () => {
const read = (taskId: string, trial: number) =>
record({ taskId, tier: "read", trial, outcome: { pass: true } });
const records: ResultRecord[] = [
// gitea-axi: t1 covered (3), t2 partial (2), t3 missing (0).
read("t1", 1),
read("t1", 2),
read("t1", 3),
read("t2", 1),
read("t2", 2),
// tea: all three tasks covered (3 each).
{ ...read("t1", 1), arm: "tea" },
{ ...read("t1", 2), arm: "tea" },
{ ...read("t1", 3), arm: "tea" },
{ ...read("t2", 1), arm: "tea" },
{ ...read("t2", 2), arm: "tea" },
{ ...read("t2", 3), arm: "tea" },
{ ...read("t3", 1), arm: "tea" },
{ ...read("t3", 2), arm: "tea" },
{ ...read("t3", 3), arm: "tea" },
];
const input = {
records,
suite: [
{ id: "t1", tier: "read" as const },
{ id: "t2", tier: "read" as const },
{ id: "t3", tier: "read" as const },
],
bonus: [],
};
// A partial matrix renders without error.
expect(() => renderReport(aggregate(input))).not.toThrow();
const output = renderReport(aggregate(input));
// The coverage annotation names the reporting floor it is measured against.
expect(output).toContain("reporting floor of 3");
// gitea-axi is incomplete: 1 partial, 1 missing, flagged incomplete.
const axiLine = output
.split("\n")
.find((l) => /\bgitea-axi\b/.test(l) && l.includes("partial"));
if (!axiLine) throw new Error("expected a gitea-axi coverage annotation");
expect(axiLine).toContain("1 partial");
expect(axiLine).toContain("1 missing");
expect(axiLine).toContain("incomplete");
// tea is complete and is not labelled incomplete.
const teaLine = output
.split("\n")
.find((l) => /(^|[^a-z-])tea([^a-z-]|$)/.test(l) && l.includes("complete"));
if (!teaLine) throw new Error("expected a tea coverage annotation");
expect(teaLine).toContain("complete");
expect(teaLine).not.toContain("incomplete");
});
// Behavior: the per-tier breakdown, the per-token-component breakdown, and the
// separate bonus table are all rendered from the same records — each as its
// own section surfacing its content. A single gitea-axi sample on a read task
// fixes the component means (one sample, so each mean equals its raw value):
// fresh input 300, cache write 80, cache read 1000, output 60 — derived BY
// HAND, independent of how the report renders. Assertions are presence-based
// (section headers and content strings), not column layout, so they survive a
// rendering refactor.
it("renders the per-tier, per-token-component, and bonus sections from the same records", () => {
const output = renderReport(
aggregate({
records: [
record({
taskId: "r1",
tier: "read",
trial: 1,
tokens: {
freshInput: 300,
cacheCreation: 80,
cacheRead: 1000,
output: 60,
},
outcome: { pass: true },
}),
],
suite: [
{ id: "r1", tier: "read" },
{ id: "m1", tier: "single-mutation" },
],
bonus: [
{
id: "bonus-x",
operation: "Do the X thing",
direction: "gitea-axi-advantage",
note: "only gitea-axi does X",
giteaAxi: "applicable",
},
],
}),
);
const lower = output.toLowerCase();
// Per-tier section: a header naming tiers, and every tier surfaced.
expect(lower).toContain("tier");
expect(output).toContain("read");
expect(output).toContain("single-mutation");
expect(output).toContain("find-then-act");
expect(output).toContain("multi-step");
// Per-token-component section: a header, the four component labels, and
// gitea-axi's component means.
expect(lower).toContain("component");
expect(lower).toContain("fresh input");
expect(lower).toContain("cache write");
expect(lower).toContain("cache read");
expect(lower).toContain("output");
expect(output).toContain("300");
expect(output).toContain("80");
expect(output).toContain("1000");
expect(output).toContain("60");
// Bonus section: a header, the operation text, and gitea-axi's applicability.
expect(lower).toContain("bonus");
expect(output).toContain("Do the X thing");
expect(output).toContain("applicable");
});
});
describe("renderReport over an append-only sample store", () => {
let rootA: string;
let rootB: string;
beforeEach(() => {
rootA = mkdtempSync(join(tmpdir(), "bench-aggregate-"));
rootB = mkdtempSync(join(tmpdir(), "bench-aggregate-"));
});
afterEach(() => {
rmSync(rootA, { recursive: true, force: true });
rmSync(rootB, { recursive: true, force: true });
});
// Behavior: rendering is stable — the rendered report is a function of the
// accumulated set of samples only, so the order in which samples were
// appended to the store must not change the output. This is a stability
// property a correct implementation already satisfies, exercised end to end
// through the real append-only store. The same records are appended to two
// independent stores in genuinely different orders (one reversed and with
// cells interleaved differently); the two rendered reports must be identical.
it("renders identically regardless of the order samples were appended", () => {
// A set spanning two arms and two tasks; the gitea-axi/t1 cell reaches the
// reporting floor of three so the headline and coverage sections are
// non-trivial rather than all placeholders.
const records: ResultRecord[] = [
record({ arm: "gitea-axi", taskId: "t1", tier: "read", trial: 1 }),
record({ arm: "gitea-axi", taskId: "t1", tier: "read", trial: 2 }),
record({ arm: "gitea-axi", taskId: "t1", tier: "read", trial: 3 }),
record({ arm: "gitea-axi", taskId: "t2", tier: "single-mutation", trial: 1 }),
record({ arm: "tea", taskId: "t1", tier: "read", trial: 1 }),
record({ arm: "tea", taskId: "t2", tier: "single-mutation", trial: 1 }),
record({ arm: "tea", taskId: "t2", tier: "single-mutation", trial: 2 }),
];
// Two genuinely different append orders over the same set.
const orderA = records;
const orderB = [...records].reverse();
// The two orders truly differ, so the stability claim is not vacuous.
expect(orderB).not.toEqual(orderA);
const storeA = createSampleStore(rootA);
for (const r of orderA) storeA.append(r);
const storeB = createSampleStore(rootB);
for (const r of orderB) storeB.append(r);
const suite = [
{ id: "t1", tier: "read" as const },
{ id: "t2", tier: "single-mutation" as const },
];
const a = renderReport(
aggregate({ records: readAllSamples(storeA), suite, bonus: [] }),
);
const b = renderReport(
aggregate({ records: readAllSamples(storeB), suite, bonus: [] }),
);
// Append order does not affect the rendered report.
expect(a).toBe(b);
// Non-vacuity: the report actually rendered real headline content.
expect(a.length).toBeGreaterThan(0);
expect(a).toContain("gitea-axi");
expect(a.toLowerCase()).toContain("cost-equivalent");
});
});

457
bench/aggregate.ts Normal file
View File

@@ -0,0 +1,457 @@
// The aggregator: the pure seam that renders the accumulated sample store into a
// readable comparison. It reads whatever samples exist and annotates incomplete
// coverage rather than blocking on a complete matrix, so a half-run benchmark
// still produces a non-misleading table.
//
// The headline metric — cost-equivalent tokens — is computed here at render time
// by weighting each run's four retained token components by Anthropic's published
// API pricing ratios (see ADR 0014). Nothing is pre-summed in the stored records,
// so the data can be re-weighted without re-running if the subscription's weekly
// accounting is ever documented.
//
// This module is a pure function of the records plus the task definitions: no I/O
// beyond `readAllSamples`, which drains a sample store into a flat record list.
// Everything else — aggregate and renderReport — is deterministic and unit-tested
// against synthetic sample stores.
import type { Arm, ResultRecord, Tier, TokenComponents } from "./result.js";
import { REPORTING_FLOOR } from "./run-loop.js";
import type { SampleStore } from "./store.js";
import type { BenchTask } from "./task.js";
import type { Applicability, BonusDirection, BonusTask } from "./task-suite.js";
/**
* The cost-equivalent-token weights, from ADR 0014: fresh input 1×, cache-write
* 1.25× (the 5-minute-TTL cache-write multiplier — the records retain a single
* un-TTL'd cache-creation component, so the default write price applies),
* cache-read 0.1×, output 5×. These are Anthropic's published API pricing ratios;
* changing the subscription's real accounting means changing only these numbers,
* since the stored records keep the four components un-weighted.
*/
export const COST_EQUIVALENT_WEIGHTS: Readonly<Record<keyof TokenComponents, number>> = {
freshInput: 1,
cacheCreation: 1.25,
cacheRead: 0.1,
output: 5,
};
/** The fixed arm display order; every headline and breakdown lists arms in it. */
export const ARM_ORDER: readonly Arm[] = ["gitea-axi", "tea", "gitea-mcp", "raw-api"];
/** The fixed tier display order for the per-tier breakdown. */
export const TIER_ORDER: readonly Tier[] = [
"read",
"single-mutation",
"find-then-act",
"multi-step",
];
/** Weight one run's four token components into a single cost-equivalent token count. */
export function costEquivalentTokens(tokens: TokenComponents): number {
return (
tokens.freshInput * COST_EQUIVALENT_WEIGHTS.freshInput +
tokens.cacheCreation * COST_EQUIVALENT_WEIGHTS.cacheCreation +
tokens.cacheRead * COST_EQUIVALENT_WEIGHTS.cacheRead +
tokens.output * COST_EQUIVALENT_WEIGHTS.output
);
}
/** Sum one run's four token components at 1× — the raw, assumption-free total. */
export function rawTokens(tokens: TokenComponents): number {
return tokens.freshInput + tokens.cacheCreation + tokens.cacheRead + tokens.output;
}
/**
* How much of an arm's slice of the matrix has been run. A cell is one task; it is
* `covered` once it holds at least the reporting floor of samples, `partial` while
* it holds fewer, and `missing` while it holds none. The three always sum to
* `tasksTotal`, so partial and missing coverage is annotated rather than hidden.
*/
export interface Coverage {
tasksTotal: number;
covered: number;
partial: number;
missing: number;
}
/** One headline row: an arm's per-run means plus its success rate and coverage. */
export interface ArmHeadline {
arm: Arm;
/** Samples the arm holds across all its cells. */
samples: number;
/** Mean cost-equivalent tokens per run; `null` when the arm has no samples. */
costEquivalentTokens: number | null;
/** Mean raw token sum per run; `null` when the arm has no samples. */
rawTokens: number | null;
/** Mean turns per run; `null` when the arm has no samples. */
turns: number | null;
/** Mean wall-clock duration (ms) per run; `null` when the arm has no samples. */
durationMs: number | null;
/** Fraction of runs that passed (0..1); `null` when the arm has no samples. */
successRate: number | null;
/** Mean imputed cost (USD) per run; `null` when the arm has no samples. */
imputedCostUsd: number | null;
coverage: Coverage;
}
/** One arm's slice of a per-tier breakdown. */
export interface TierArm {
arm: Arm;
samples: number;
costEquivalentTokens: number | null;
successRate: number | null;
coverage: Coverage;
}
/** The per-tier breakdown for one tier, one row per arm. */
export interface TierBreakdown {
tier: Tier;
arms: TierArm[];
}
/** One arm's mean per-run token component breakdown; `null` fields when no samples. */
export interface ComponentBreakdown {
arm: Arm;
freshInput: number | null;
cacheCreation: number | null;
cacheRead: number | null;
output: number | null;
}
/** One arm's run metrics for a bonus operation, when any samples exist for it. */
export interface BonusArmSamples {
arm: Arm;
samples: number;
costEquivalentTokens: number | null;
successRate: number | null;
}
/** One row of the bonus table: a capability-asymmetric operation and its status. */
export interface BonusRow {
id: string;
operation: string;
direction: BonusDirection;
note: string;
/** gitea-axi's own applicability for the operation, from the definition. */
giteaAxi: Applicability;
/** Per-arm run metrics from the records; usually empty (bonus cells are rarely run). */
arms: BonusArmSamples[];
}
/** The fully-aggregated report, ready to render. */
export interface Report {
/** The floor a cell's sample count must reach to count as covered. */
reportingFloor: number;
headline: ArmHeadline[];
tiers: TierBreakdown[];
components: ComponentBreakdown[];
bonus: BonusRow[];
}
/**
* The task facts the aggregator needs to score coverage: an id to key each cell
* and a tier to group it under. The real scored suite (`BenchTask[]`) satisfies
* this, but so does a bare `{ id, tier }` list, since the aggregator never scores.
*/
export type TaskCoverage = Pick<BenchTask, "id" | "tier">;
/** Everything the aggregator needs: the samples and the definitions to score coverage against. */
export interface AggregateInput {
records: readonly ResultRecord[];
suite: readonly TaskCoverage[];
bonus: readonly BonusTask[];
/** Samples a cell needs before it counts as covered; defaults to {@link REPORTING_FLOOR}. */
reportingFloor?: number;
}
/** Drain every sample the store holds into one flat, append-order record list. */
export function readAllSamples(store: SampleStore): ResultRecord[] {
return store.cells().flatMap((cell) => store.read(cell));
}
/** The arithmetic mean of a list, or `null` for an empty list (no data to average). */
function mean(values: number[]): number | null {
if (values.length === 0) {
return null;
}
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
/** The fraction of runs that passed, or `null` when there are no runs. */
function passRate(records: readonly ResultRecord[]): number | null {
if (records.length === 0) {
return null;
}
return records.filter((record) => record.outcome.pass).length / records.length;
}
/** Mean cost-equivalent tokens per run over a record set, or `null` when empty. */
function meanCostEquivalent(records: readonly ResultRecord[]): number | null {
return mean(records.map((record) => costEquivalentTokens(record.tokens)));
}
/**
* Count how a task set's cells are covered by one arm's records: a cell is
* `covered` once it holds at least the floor of samples, `partial` below it,
* `missing` at zero. The three always sum to the task count, so partial and
* missing coverage is annotated rather than hidden.
*/
function coverageOf(
tasks: readonly TaskCoverage[],
armRecords: readonly ResultRecord[],
floor: number,
): Coverage {
let covered = 0;
let partial = 0;
let missing = 0;
for (const task of tasks) {
const count = armRecords.filter((record) => record.taskId === task.id).length;
if (count >= floor) {
covered += 1;
} else if (count > 0) {
partial += 1;
} else {
missing += 1;
}
}
return { tasksTotal: tasks.length, covered, partial, missing };
}
/** Aggregate the records against the task definitions into a renderable report. */
export function aggregate(input: AggregateInput): Report {
const floor = input.reportingFloor ?? REPORTING_FLOOR;
const headline = ARM_ORDER.map((arm): ArmHeadline => {
const armRecords = input.records.filter((record) => record.arm === arm);
return {
arm,
samples: armRecords.length,
costEquivalentTokens: meanCostEquivalent(armRecords),
rawTokens: mean(armRecords.map((record) => rawTokens(record.tokens))),
turns: mean(armRecords.map((record) => record.turns)),
durationMs: mean(armRecords.map((record) => record.durationMs)),
successRate: passRate(armRecords),
imputedCostUsd: mean(armRecords.map((record) => record.imputedCostUsd)),
coverage: coverageOf(input.suite, armRecords, floor),
};
});
const tiers = TIER_ORDER.map((tier): TierBreakdown => {
const tierTasks = input.suite.filter((task) => task.tier === tier);
const tierRecords = input.records.filter((record) => record.tier === tier);
const arms = ARM_ORDER.map((arm): TierArm => {
const armRecords = tierRecords.filter((record) => record.arm === arm);
return {
arm,
samples: armRecords.length,
costEquivalentTokens: meanCostEquivalent(armRecords),
successRate: passRate(armRecords),
coverage: coverageOf(tierTasks, armRecords, floor),
};
});
return { tier, arms };
});
const components = ARM_ORDER.map((arm): ComponentBreakdown => {
const armRecords = input.records.filter((record) => record.arm === arm);
return {
arm,
freshInput: mean(armRecords.map((record) => record.tokens.freshInput)),
cacheCreation: mean(armRecords.map((record) => record.tokens.cacheCreation)),
cacheRead: mean(armRecords.map((record) => record.tokens.cacheRead)),
output: mean(armRecords.map((record) => record.tokens.output)),
};
});
const bonus = input.bonus.map((definition): BonusRow => {
const cellRecords = input.records.filter((record) => record.taskId === definition.id);
const arms = ARM_ORDER.flatMap((arm): BonusArmSamples[] => {
const armRecords = cellRecords.filter((record) => record.arm === arm);
if (armRecords.length === 0) {
return [];
}
return [
{
arm,
samples: armRecords.length,
costEquivalentTokens: meanCostEquivalent(armRecords),
successRate: passRate(armRecords),
},
];
});
return {
id: definition.id,
operation: definition.operation,
direction: definition.direction,
note: definition.note,
giteaAxi: definition.giteaAxi,
arms,
};
});
return { reportingFloor: floor, headline, tiers, components, bonus };
}
/** Per-column horizontal alignment for the text tables. */
type Align = "left" | "right";
/** Render a value with `format`, or the em-dash placeholder when it is absent. */
function cell(value: number | null, format: (value: number) => string): string {
return value === null ? "—" : format(value);
}
/** Round to a whole number — the scale token counts are reported at. */
const asInteger = (value: number): string => String(Math.round(value));
/** One decimal place — for the soft turn and (via seconds) duration metrics. */
const asDecimal = (value: number): string => value.toFixed(1);
/** Milliseconds as seconds, since durations are seconds-scale and network-soft. */
const asSeconds = (value: number): string => `${(value / 1000).toFixed(1)}s`;
/** A 0..1 rate as a whole-percent success figure. */
const asPercent = (value: number): string => `${Math.round(value * 100)}%`;
/** Imputed dollars, kept to cents and marked approximate and secondary. */
const asImputedDollars = (value: number): string => `~$${value.toFixed(2)}`;
/** A coverage figure as covered-of-total cells. */
const asCoverage = (coverage: Coverage): string => `${coverage.covered}/${coverage.tasksTotal}`;
/**
* Render a table as aligned, space-separated columns. Column widths fit their
* widest cell, so rendering is stable for a given set of rows regardless of the
* order they were accumulated in.
*/
function renderTable(headers: string[], aligns: Align[], rows: string[][]): string[] {
const widths = headers.map((header, column) =>
Math.max(header.length, ...rows.map((row) => (row[column] ?? "").length)),
);
const pad = (text: string, column: number): string =>
aligns[column] === "right" ? text.padStart(widths[column]!) : text.padEnd(widths[column]!);
const line = (values: string[]): string =>
values.map((value, column) => pad(value, column)).join(" ").trimEnd();
return [line(headers), ...rows.map(line)];
}
/**
* The headline table: one row per arm, cost-equivalent tokens as the headline
* metric, then the raw token sum, turns, duration, success rate, and a coverage
* figure, with imputed cost as a de-emphasized (parenthesized, approximate)
* trailing column. Metric cells for an arm with no samples read as an em dash.
*/
function renderHeadline(report: Report): string[] {
const headers = ["arm", "cost-eq", "raw", "turns", "duration", "success", "coverage", "(imputed $)"];
const aligns: Align[] = ["left", "right", "right", "right", "right", "right", "right", "right"];
const rows = report.headline.map((row) => [
row.arm,
cell(row.costEquivalentTokens, asInteger),
cell(row.rawTokens, asInteger),
cell(row.turns, asDecimal),
cell(row.durationMs, asSeconds),
cell(row.successRate, asPercent),
asCoverage(row.coverage),
cell(row.imputedCostUsd, asImputedDollars),
]);
return [
"Headline — cost-equivalent tokens (the headline metric), one row per arm:",
...renderTable(headers, aligns, rows),
];
}
/**
* The coverage annotation: one line per arm marking its coverage complete or
* incomplete, with the partial (below-floor) and missing (unsampled) cell counts
* spelled out. This is what keeps a half-run matrix from reading as a full one —
* an arm is never silently presented as complete when cells are still missing.
*/
function renderCoverage(report: Report): string[] {
const tasksTotal = report.headline[0]?.coverage.tasksTotal ?? 0;
const header = `Coverage — cells at or above the reporting floor of ${report.reportingFloor} samples, out of ${tasksTotal} tasks per arm:`;
const lines = report.headline.map((row) => {
const { covered, tasksTotal: total, partial, missing } = row.coverage;
if (partial === 0 && missing === 0) {
return ` ${row.arm}: ${covered}/${total} covered — complete`;
}
return ` ${row.arm}: ${covered}/${total} covered, ${partial} partial, ${missing} missing — incomplete`;
});
return [header, ...lines];
}
/**
* The per-tier breakdown: for each tier, a small arm table of cost-equivalent
* tokens (mean), success rate, and that tier's coverage. Shows where an arm wins
* or loses across the tiers, derived from the same records as the headline.
*/
function renderTiers(report: Report): string[] {
const headers = ["arm", "cost-eq", "success", "coverage"];
const aligns: Align[] = ["left", "right", "right", "right"];
const lines = ["Per-tier breakdown — cost-equivalent tokens (mean) and success rate by tier:"];
for (const tier of report.tiers) {
const rows = tier.arms.map((arm) => [
arm.arm,
cell(arm.costEquivalentTokens, asInteger),
cell(arm.successRate, asPercent),
asCoverage(arm.coverage),
]);
lines.push(` ${tier.tier}`);
for (const line of renderTable(headers, aligns, rows)) {
lines.push(` ${line}`);
}
}
return lines;
}
/**
* The per-token-component breakdown: each arm's mean tokens per run split into
* the four retained components, so a reader can see what drives an arm's cost
* (typically cache-read volume). The component labels name the pricing tiers the
* cost-equivalent weights apply to.
*/
function renderComponents(report: Report): string[] {
const headers = ["arm", "fresh input", "cache write", "cache read", "output"];
const aligns: Align[] = ["left", "right", "right", "right", "right"];
const rows = report.components.map((row) => [
row.arm,
cell(row.freshInput, asInteger),
cell(row.cacheCreation, asInteger),
cell(row.cacheRead, asInteger),
cell(row.output, asInteger),
]);
return [
"Per-token-component breakdown — mean tokens per run by component:",
...renderTable(headers, aligns, rows),
];
}
/**
* The separate bonus table: the capability-asymmetric operations kept out of the
* scored comparison, each with its direction, gitea-axi's own applicability, and a
* note. Any arm that was actually run against a bonus cell is shown inline; most
* are unrun, in which case only the capability annotation is reported.
*/
function renderBonus(report: Report): string[] {
const lines = ["Bonus table — capability-asymmetric operations (outside the scored comparison):"];
for (const row of report.bonus) {
lines.push(` ${row.operation}`);
lines.push(` direction: ${row.direction}; gitea-axi: ${row.giteaAxi}`);
lines.push(` note: ${row.note}`);
if (row.arms.length === 0) {
lines.push(" runs: none");
} else {
for (const arm of row.arms) {
lines.push(
` runs: ${arm.arm}${cell(arm.costEquivalentTokens, asInteger)} cost-eq, ${cell(arm.successRate, asPercent)} pass (${arm.samples} sample(s))`,
);
}
}
}
return lines;
}
/** Render an aggregated report into a stable, human-readable text block. */
export function renderReport(report: Report): string {
const sections = [
renderHeadline(report),
renderCoverage(report),
renderTiers(report),
renderComponents(report),
renderBonus(report),
];
return `${sections.map((section) => section.join("\n")).join("\n\n")}\n`;
}

178
bench/arm.test.ts Normal file
View File

@@ -0,0 +1,178 @@
import { mkdtempSync, readdirSync, readlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Arm } from "./result.js";
import { basePrompt, buildArm, type SharedContext } from "./arm.js";
/**
* The shared context every arm is handed. Its values are distinctive literals so
* that finding them echoed back in a prompt is unambiguous evidence, not a
* coincidence — the coordinates ("acme/bench-xyz"), the host URL, and the token
* are all chosen here, independent of the module under test.
*/
const context: SharedContext = {
coords: { owner: "acme", repo: "bench-xyz" },
access: { apiUrl: "https://git.example.test", token: "s3cr3t-token" },
};
/** Every arm the benchmark compares (ADR / result.ts); an independent literal list. */
const allArms: ReadonlyArray<Arm> = ["gitea-axi", "tea", "gitea-mcp", "raw-api"];
describe("basePrompt", () => {
// Behavior: the task-agnostic base prompt carries the same repository
// coordinates, host URL, and token the harness was given — these are the facts
// every arm must share (benchmark-harness spec, "Scaffolding").
it("echoes the repository coordinates, host URL, and token from the context", () => {
const prompt = basePrompt(context);
expect(prompt).toContain("acme/bench-xyz");
expect(prompt).toContain("https://git.example.test");
expect(prompt).toContain("s3cr3t-token");
});
});
describe("buildArm", () => {
let binRoot: string;
beforeEach(() => {
binRoot = mkdtempSync(join(tmpdir(), "bench-arm-"));
});
afterEach(() => {
rmSync(binRoot, { recursive: true, force: true });
});
// Fake resolver so provisioning a curated bin dir never depends on binaries
// present on the host; dangling symlinks are fine.
const locate = (binary: string) => `/fake/bin/${binary}`;
// Behavior: all arms share the identical base prompt — the base is a common
// prefix of every arm's system prompt, and arms differ only in the bootstrap
// appended after it. The expected prefix is basePrompt(context), computed
// independently of buildArm.
it.each(allArms)(
"prefixes the %s arm's system prompt with the identical shared base prompt",
(arm) => {
const base = basePrompt(context);
const definition = buildArm(arm, context, { binRoot, locate });
expect(definition.systemPrompt.startsWith(base)).toBe(true);
},
);
// Behavior: the gitea-axi arm's assembled context carries the bundled Agent
// Skill, because the Skill ships with the product and its token cost is charged
// to gitea-axi (benchmark-harness spec, "Scaffolding"). "agent-ergonomic CLI"
// is a distinctive phrase from the shipped skills/gitea-axi/SKILL.md body — an
// independent literal anchor, not a value recomputed from the module.
it("carries the bundled Agent Skill in the gitea-axi arm's system prompt", () => {
const definition = buildArm("gitea-axi", context, { binRoot, locate });
expect(definition.systemPrompt).toContain("agent-ergonomic CLI");
});
// Behavior: the tea and raw-API arms each receive only a one-line
// native-discovery pointer beyond the shared base — unlike gitea-axi (whole
// skill) or gitea-mcp (eager schemas) (benchmark-harness spec, "Scaffolding").
// The bootstrap is the systemPrompt with the shared base prefix removed. Each
// pointer must be a single line and name that arm's own tool. The tool names
// ("tea", "curl" — raw-api drives the API via curl, ADR 0016) are independent
// literals fixed by the spec, not recomputed from the module.
it.each([
{ arm: "tea" as Arm, tool: "tea" },
{ arm: "raw-api" as Arm, tool: "curl" },
])(
"gives the $arm arm only a one-line native-discovery pointer naming $tool",
({ arm, tool }) => {
const definition = buildArm(arm, context, { binRoot, locate });
const bootstrap = definition.systemPrompt.slice(basePrompt(context).length).trim();
expect(bootstrap.split("\n")).toHaveLength(1);
expect(bootstrap).toContain(tool);
},
);
// Behavior: the gitea-mcp arm is MCP-only — its shell tool is disabled and only
// the MCP tools are attached, reaching the same host and token as the shared
// context (benchmark-harness spec, "Tool isolation" / "Scaffolding"). Eager
// schema loading is inherent to attaching the MCP server, so the observable
// facts are: no shell config, an attached MCP server, and that server's env
// carrying the fixture's host URL and token (independent literals, not read
// from the module). Env-var KEY names are deliberately not asserted, so the
// test does not couple to launch-detail naming.
it("makes the gitea-mcp arm MCP-only: shell disabled, MCP attached with the shared host and token", () => {
const definition = buildArm("gitea-mcp", context, { binRoot, locate });
expect(definition.shell).toBeNull();
expect(definition.mcp).not.toBeNull();
const envValues = Object.values(definition.mcp!.server.env);
expect(envValues).toContain("https://git.example.test");
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 —
// ADR 0016 fixes exactly one allowed binary per shell arm — not values read
// back from the module. For each shell arm: it is not an MCP arm; its curated
// bin dir exposes only its own binary (symlinked to the injected target); its
// PATH leads with that curated dir; and its guard permits its own binary while
// denying a foreign one.
const shellArms = [
{ arm: "gitea-axi", binary: "gitea-axi", foreign: "tea issues list" },
{ arm: "tea", binary: "tea", foreign: "curl https://x" },
{ arm: "raw-api", binary: "curl", foreign: "tea issues list" },
] as const;
it.each(shellArms)(
"configures the $arm arm's PATH and guard from the guard, exposing only $binary",
({ arm, binary, foreign }) => {
const definition = buildArm(arm, context, { binRoot, locate });
expect(definition.mcp).toBeNull();
const shell = definition.shell;
expect(shell).not.toBeNull();
if (shell === null) return;
// Curated bin dir exposes ONLY this arm's allowed binary, symlinked to the
// injected resolver's target.
expect(readdirSync(shell.binDir)).toEqual([binary]);
expect(readlinkSync(join(shell.binDir, binary))).toBe(`/fake/bin/${binary}`);
// PATH leads with the curated dir, so the arm's binary is found there first.
expect(shell.path.split(":")[0]).toBe(shell.binDir);
// The guard is bound to this arm: its own binary passes, a foreign one is denied.
expect(shell.guard(`${binary} --help`).allowed).toBe(true);
expect(shell.guard(foreign).allowed).toBe(false);
},
);
});

230
bench/arm.ts Normal file
View File

@@ -0,0 +1,230 @@
// Per-arm scaffolding: the single arm definition the runner consumes for one
// cell. Every arm shares one task-agnostic base prompt and the same repository
// coordinates and token; each arm then receives a minimal, symmetric bootstrap
// naming its tool and pointing at that tool's own native discovery affordance.
//
// The deliberate asymmetries follow the shipped products (see the benchmark
// spec's Scaffolding section): the gitea-axi arm loads the bundled Agent Skill,
// because the Skill ships with the product and its token cost belongs to
// gitea-axi; the tea and raw-api arms get a one-line native-discovery pointer;
// the gitea-mcp arm's dispatcher schemas load eagerly as its ambient cost and it
// runs with the shell disabled, attaching only the MCP tools.
//
// This module assembles the prompt and composes the guard (guard.ts) for the
// tool/PATH configuration; it does not run the agent — the runner (a later
// slice) consumes an ArmDefinition and drives the Claude Agent SDK.
import { readFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { guardCommand, provisionArmBin, type GuardDecision } from "./guard.js";
import type { Arm } from "./result.js";
import type { BenchAccess, RepoCoords } from "./seed.js";
/**
* The task-agnostic inputs handed identically to every arm of a cell: the
* throwaway repository's coordinates and the host access (base URL and token).
*/
export interface SharedContext {
coords: RepoCoords;
access: BenchAccess;
}
/**
* The tool/PATH configuration for a shell-driving arm, derived from the guard.
* `null` on an ArmDefinition marks an arm that runs with the shell disabled.
*/
export interface ArmShell {
/** Curated bin directory exposing only the arm's one allowed binary. */
binDir: string;
/** PATH value: the curated bin dir prepended to the ambient PATH. */
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>;
}
/**
* The MCP attachment for the gitea-mcp arm. The runner launches the server over
* stdio and attaches its dispatcher tools, whose schemas load eagerly on connect
* as the arm's ambient cost. `null` on an ArmDefinition marks an arm that reaches
* Gitea through the shell instead.
*/
export interface ArmMcp {
server: {
command: string;
args: string[];
env: Record<string, string>;
};
}
/**
* Everything the runner needs to run one arm: the fully assembled system prompt
* and the tool configuration. Exactly one of `shell` / `mcp` is non-null.
*/
export interface ArmDefinition {
arm: Arm;
systemPrompt: string;
shell: ArmShell | null;
mcp: ArmMcp | null;
}
/** Options controlling how an arm is built; the runner supplies a trial scratch dir. */
export interface BuildArmOptions {
/** Directory under which the arm's curated bin dir is created (shell arms). */
binRoot: string;
/** Resolver for a binary's absolute path; injectable for host-independent tests. */
locate?: (binary: string) => string | null;
/** Override the bundled skill file's path (defaults to the shipped SKILL.md). */
skillPath?: string;
}
/**
* The identical, task-agnostic base prompt every arm's assembled prompt begins
* with. It carries only the facts shared by all arms — the repository
* coordinates, the host URL, and the token — and never names a specific tool or
* task, so the base is byte-for-byte the same across arms and the per-arm
* bootstrap is the only difference in the assembled prompt.
*/
export function basePrompt(context: SharedContext): string {
const { owner, repo } = context.coords;
const { apiUrl, token } = context.access;
return [
"You are a coding agent operating on a single Gitea repository.",
"",
`Repository: ${owner}/${repo}`,
`Gitea host: ${apiUrl}`,
`Access token: ${token}`,
"",
"Authenticate every request with that token, and confine your work to that",
"repository. When the task asks a question, state your final answer plainly.",
].join("\n");
}
/**
* The bundled Agent Skill's shipped location, resolved relative to this module
* the same way the product resolves it (see src/commands/setup.ts's
* `SKILL_SOURCE`). bench/ runs from source, so `import.meta.url` points at this
* file and `../skills/...` lands at the repository's shipped skill.
*/
const DEFAULT_SKILL_PATH = new URL("../skills/gitea-axi/SKILL.md", import.meta.url);
/**
* Read the bundled Agent Skill's body, stripping its YAML frontmatter. Only the
* instructional body is charged to the gitea-axi arm: the frontmatter's
* `description` is metadata Claude Code loads ambiently for every skill, so
* folding it in here would double-count it against this one arm.
*/
function loadSkillBody(skillPath: string | URL): string {
const raw = readFileSync(skillPath, "utf8");
const match = raw.match(/^---\n[\s\S]*?\n---\n/);
return (match ? raw.slice(match[0].length) : raw).trim();
}
/**
* The per-arm bootstrap appended after the shared base: the minimal, symmetric
* text naming the arm's tool and pointing at its native discovery affordance.
* The gitea-axi arm is the deliberate asymmetry — it embeds the bundled Agent
* Skill, whose token cost belongs to the shipped product.
*/
function armBootstrap(arm: Arm, context: SharedContext, options: BuildArmOptions): string {
switch (arm) {
case "gitea-axi": {
const skill = loadSkillBody(options.skillPath ?? DEFAULT_SKILL_PATH);
return [
"You have the `gitea-axi` CLI available in your shell. Its bundled Agent",
"Skill follows; treat it as your guide to the tool.",
"",
skill,
].join("\n");
}
case "tea":
return "You have the `tea` CLI available in your shell; run `tea --help` to discover its commands.";
case "raw-api":
return `You have \`curl\` available in your shell; the Gitea REST API is documented at ${context.access.apiUrl}/api/swagger.`;
case "gitea-mcp":
return "The Gitea MCP server's tools are attached; use them to operate on the repository.";
}
}
/**
* The MCP attachment for the gitea-mcp arm: the official server launched over
* stdio, pointed at the shared host and token through the environment variables
* it reads (`GITEA_HOST`, `GITEA_ACCESS_TOKEN`). Attaching it is what loads the
* dispatcher schemas eagerly — the SDK lists the server's tools on connect — so
* that ambient cost is charged to this arm.
*/
function mcpAttachment(context: SharedContext): ArmMcp {
return {
server: {
command: "gitea-mcp",
args: ["-t", "stdio"],
env: {
GITEA_HOST: context.access.apiUrl,
GITEA_ACCESS_TOKEN: context.access.token,
},
},
};
}
/**
* Build the tool/PATH configuration for a shell-driving arm from the guard:
* provision a curated bin directory exposing only the arm's one allowed binary,
* lead the PATH with it, and bind the authoritative guard to the arm. The
* 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, context: SharedContext, options: BuildArmOptions): ArmShell | null {
if (arm === "gitea-mcp") {
return null;
}
const binDir = join(options.binRoot, arm);
provisionArmBin(arm, binDir, options.locate);
const ambient = process.env.PATH ?? "";
return {
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, context, options),
mcp: arm === "gitea-mcp" ? mcpAttachment(context) : null,
};
}

97
bench/audit.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildArm, type SharedContext } from "./arm.js";
import { auditTranscript, type ToolUse } from "./audit.js";
/**
* The shared context handed to every arm. Its values are distinctive literals so
* that they are unambiguous in any assertion, independent of the module under
* test — mirrors the fixture in arm.test.ts.
*/
const context: SharedContext = {
coords: { owner: "acme", repo: "bench-xyz" },
access: { apiUrl: "https://git.example.test", token: "s3cr3t-token" },
};
// Fake resolver so building an arm never depends on binaries present on the
// host; dangling symlinks in the curated bin dir are fine (see arm.test.ts).
const locate = (binary: string) => `/fake/bin/${binary}`;
describe("auditTranscript", () => {
let binRoot: string;
beforeEach(() => {
binRoot = mkdtempSync(join(tmpdir(), "bench-audit-"));
});
afterEach(() => {
rmSync(binRoot, { recursive: true, force: true });
});
// Behavior: a shell-driving arm whose transcript reaches only its own
// allow-listed binary and curated harmless utilities audits clean — nothing
// leaked (benchmark-harness spec, "Tool isolation"). The tea arm's allowed
// binary is `tea`, and `grep` is a curated harmless utility (guard.ts's
// ARM_BINARY / HARMLESS_BINARIES) — independent literals fixed by the guard's
// contract, not recomputed from the audit implementation. The expected verdict
// is therefore a clean result with no leaks.
it("passes a tea-arm transcript of only its own binary and harmless utilities as clean", () => {
const arm = buildArm("tea", context, { binRoot, locate });
const transcript: ToolUse[] = [
{ kind: "shell", command: "tea issues list" },
{ kind: "shell", command: "tea issues list | grep bug" },
];
const result = auditTranscript(arm, transcript);
expect(result.clean).toBe(true);
});
// Behavior: a run in which a foreign tool was reached is flagged invalid
// instead of scored — the transcript audits as a leak (benchmark-harness spec,
// "Tool isolation"). On the tea arm, `curl` is a network tool the guard denies
// (it is explicitly excluded from guard.ts's HARMLESS_BINARIES), so a
// transcript that reaches it is NOT clean and reports at least one leak. `curl`
// being foreign to the tea arm is an independent literal fixed by the guard's
// contract, not recomputed from the audit implementation.
it("flags a tea-arm transcript that reaches a foreign binary as a leak", () => {
const arm = buildArm("tea", context, { binRoot, locate });
const transcript: ToolUse[] = [
{ kind: "shell", command: "tea issues list" },
{ kind: "shell", command: "curl https://git.example.test/api/v1/repos/acme/bench-xyz/issues" },
];
const result = auditTranscript(arm, transcript);
expect(result.clean).toBe(false);
if (!result.clean) {
expect(result.leaks.length).toBeGreaterThan(0);
}
});
// Behavior: the gitea-mcp arm runs with the shell disabled — it reaches Gitea
// only through its attached MCP tools (guard.ts's ARM_BINARY is null for it,
// arm.ts leaves its ArmDefinition.shell null). So any shell command in a
// gitea-mcp transcript means the shell was reached on a shell-disabled arm,
// which is a leak, while a genuine MCP tool call on this arm is legitimate. The
// load-bearing verdict is NOT clean with at least one leak, fixed by the
// arm/guard contract rather than the audit implementation. A legitimate mcp
// entry is included to show it is the shell entry — not the mcp entry — that
// leaks.
it("flags a shell command on the shell-disabled gitea-mcp arm as a leak", () => {
const arm = buildArm("gitea-mcp", context, { binRoot, locate });
const transcript: ToolUse[] = [
{ kind: "mcp", server: "gitea-mcp", tool: "list_repo_issues" },
{ kind: "shell", command: "tea issues list" },
];
const result = auditTranscript(arm, transcript);
expect(result.clean).toBe(false);
if (!result.clean) {
expect(result.leaks.length).toBeGreaterThan(0);
}
});
});

86
bench/audit.ts Normal file
View File

@@ -0,0 +1,86 @@
// The post-run transcript audit: a defence-in-depth check that re-inspects a
// completed run's tool invocations and asserts no foreign tool was reached. The
// guard (guard.ts) is the primary, in-band enforcement — it denies a foreign
// shell command before it runs — but the audit is the independent backstop the
// benchmark trusts: if enforcement ever leaked, a run in which a foreign tool
// actually executed is flagged invalid rather than being scored (see the
// benchmark-harness spec's testing decisions).
//
// This module is pure — it re-runs the arm's own guard over the recorded shell
// commands and checks the arm's channel discipline (shell arms never reach MCP
// tools; the MCP arm never reaches the shell). It does not run the agent; the
// 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. 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 = TranscriptEntry;
/**
* The audit's verdict. On a leak it carries a human-readable reason per foreign
* tool that was reached, so an invalidated trial can be diagnosed from the record.
*/
export type AuditResult = { clean: true } | { clean: false; leaks: string[] };
/**
* The single source of truth for whether one tool is foreign to an arm: returns a
* human-readable reason it must not run, or `null` when it is permitted. A shell
* arm puts every Bash command through its own guard and admits every non-shell
* built-in, but has no MCP server; the MCP arm disables the shell entirely and
* admits its MCP tools. Built-in `other` tools reach no Gitea channel and are
* always permitted.
*
* Both isolation enforcement points share this predicate so they cannot drift: the
* agent driver (sdk-driver.ts) consults it in-band to deny a foreign tool before
* it runs, and `auditTranscript` re-applies it post-run as the independent backstop.
*/
export function foreignToolReason(arm: ArmDefinition, use: ToolUse): string | null {
if (use.kind === "shell") {
if (arm.shell === null) {
return `the ${arm.arm} arm runs with the shell disabled; only its MCP tools are available`;
}
const decision = arm.shell.guard(use.command);
return decision.allowed ? null : decision.reason;
}
if (use.kind === "mcp") {
return arm.mcp === null ? `the ${arm.arm} arm has no MCP server attached` : null;
}
return null;
}
/** A human-readable rendering of a leaked tool use, tagging it with the reason. */
function describeLeak(use: ToolUse, reason: string): string {
switch (use.kind) {
case "shell":
return `foreign shell command ${JSON.stringify(use.command)} reached: ${reason}`;
case "mcp":
return `MCP tool "${use.server}/${use.tool}" reached: ${reason}`;
case "other":
return `tool "${use.name}" reached: ${reason}`;
}
}
/**
* Re-check a completed run's transcript against the arm's isolation rules,
* re-applying `foreignToolReason` to every executed tool. A tool the arm should
* never have reached — a guard-denied shell command, a shell command on the MCP
* arm, an MCP call on a shell arm — is reported as a leak. Clean when nothing leaked.
*/
export function auditTranscript(arm: ArmDefinition, transcript: ToolUse[]): AuditResult {
const leaks: string[] = [];
for (const use of transcript) {
const reason = foreignToolReason(arm, use);
if (reason !== null) {
leaks.push(describeLeak(use, reason));
}
}
return leaks.length === 0 ? { clean: true } : { clean: false, leaks };
}

442
bench/checker.test.ts Normal file
View File

@@ -0,0 +1,442 @@
import { describe, expect, it } from "vitest";
import { checkMutation, checkReadAnswer, score } from "./checker.js";
import type { Submission } from "./checker.js";
import type {
Issue,
Label,
PullRequest,
RepoState,
RequiredFact,
ScoringSpec,
} from "./scoring-spec.js";
/** Build an issue with sensible defaults, overridable per test. */
function issue(overrides: Partial<Issue> = {}): Issue {
return {
number: 1,
title: "Login button misaligned",
body: "The submit button overflows on mobile.",
state: "open",
labels: [],
assignees: [],
comments: [],
...overrides,
};
}
/** Build a label with sensible defaults, overridable per test. */
function label(overrides: Partial<Label> = {}): Label {
return {
name: "bug",
color: "#d73a4a",
...overrides,
};
}
/** Build a pull request with sensible defaults, overridable per test. */
function pr(overrides: Partial<PullRequest> = {}): PullRequest {
return {
number: 5,
title: "Fix login button alignment",
body: "Closes #1.",
state: "merged",
labels: [],
assignees: [],
comments: [],
reviews: [],
...overrides,
};
}
/** Build a full repository snapshot with sensible defaults, overridable per test. */
function repo(overrides: Partial<RepoState> = {}): RepoState {
return {
labels: [],
issues: [],
pullRequests: [],
...overrides,
};
}
describe("checkMutation", () => {
it("passes when the actual state matches the expected end state after normalization", () => {
// Intended change: issue #1 is closed and the "bug" label applied. The expected
// end state fixes that outcome.
const expected = repo({
labels: [label({ name: "bug", color: "#d73a4a" })],
issues: [issue({ number: 1, state: "closed", labels: ["bug"] })],
});
// The actual post-run snapshot embodies the same outcome, but carries volatile
// host-assigned ids/timestamps and lists the label set in a different order —
// all of which normalization must ignore.
const actual = repo({
labels: [label({ name: "bug", color: "#d73a4a" })],
issues: [
issue({
number: 1,
state: "closed",
labels: ["bug"],
id: 4201,
createdAt: "2026-07-15T09:00:00Z",
updatedAt: "2026-07-16T10:30:00Z",
}),
],
});
expect(checkMutation(expected, actual)).toEqual({ pass: true });
});
it("fails and names the affected entity when the actual state is missing the intended change", () => {
// Intended change: issue #1 is closed. The expected end state fixes that outcome.
const expected = repo({
issues: [issue({ number: 1, state: "closed" })],
});
// The change did not happen: issue #1 is still open in the actual post-run state.
const actual = repo({
issues: [issue({ number: 1, state: "open" })],
});
const result = checkMutation(expected, actual);
expect(result.pass).toBe(false);
// A diagnosable failure names the affected entity so a failed trial can be
// traced back to what diverged — here, issue #1.
if (result.pass === false) {
expect(result.differences.some((d) => d.includes("#1"))).toBe(true);
}
});
it("fails and names the stray label when the actual state carries collateral change", () => {
// Intended change: issue #1 closed, carrying no applied labels. Both snapshots
// define the same repository labels and agree on the closed state of #1.
const expected = repo({
labels: [
label({ name: "bug", color: "#d73a4a" }),
label({ name: "wontfix", color: "#ffffff" }),
],
issues: [issue({ number: 1, state: "closed", labels: [] })],
});
// Collateral damage: an extra "wontfix" label was applied to issue #1 even
// though the expected end state leaves it unlabelled.
const actual = repo({
labels: [
label({ name: "bug", color: "#d73a4a" }),
label({ name: "wontfix", color: "#ffffff" }),
],
issues: [issue({ number: 1, state: "closed", labels: ["wontfix"] })],
});
const result = checkMutation(expected, actual);
expect(result.pass).toBe(false);
// The stray label must be named so the collateral change is diagnosable —
// knowing #1's labels merely "differ" does not say what was wrongly applied.
if (result.pass === false) {
expect(result.differences.some((d) => d.includes("wontfix"))).toBe(true);
}
});
it("passes when comments match by author and body despite differing order and volatile ids", () => {
// The expected issue carries two comments in one order, with one set of
// host-assigned ids and timestamps.
const expected = repo({
issues: [
issue({
number: 1,
state: "closed",
comments: [
{ author: "octocat", body: "Reproduced on mobile Safari.", id: 11, createdAt: "2026-07-15T09:00:00Z" },
{ author: "maintainer", body: "Fixed in the latest build.", id: 12, createdAt: "2026-07-15T10:00:00Z" },
],
}),
],
});
// The actual issue carries the same set of comments by (author, body), but in
// the reverse order and with entirely different volatile ids and timestamps —
// all of which normalization must ignore.
const actual = repo({
issues: [
issue({
number: 1,
state: "closed",
comments: [
{ author: "maintainer", body: "Fixed in the latest build.", id: 907, createdAt: "2026-07-16T14:22:00Z" },
{ author: "octocat", body: "Reproduced on mobile Safari.", id: 906, createdAt: "2026-07-16T14:20:00Z" },
],
}),
],
});
expect(checkMutation(expected, actual)).toEqual({ pass: true });
});
it("fails and names the missing comment when the actual state lacks a required comment", () => {
// Intended change: issue #1 must carry a specific maintainer comment asking
// for a reproduction case.
const expected = repo({
issues: [
issue({
number: 1,
comments: [{ author: "maintainer", body: "Please add a reproduction case." }],
}),
],
});
// The agent never posted that comment: the actual issue has no comments.
const actual = repo({
issues: [issue({ number: 1, comments: [] })],
});
const result = checkMutation(expected, actual);
expect(result.pass).toBe(false);
// The specific missing comment must be identifiable from its own text so the
// divergence is diagnosable, not just "comments differ".
if (result.pass === false) {
expect(result.differences.some((d) => d.includes("Please add a reproduction case."))).toBe(
true,
);
}
});
it("passes when an issue's applied labels match as a set despite differing order", () => {
// The expected end state applies two labels to issue #1 in one order.
const expected = repo({
labels: [
label({ name: "bug", color: "#d73a4a" }),
label({ name: "priority:high", color: "#b60205" }),
],
issues: [issue({ number: 1, state: "closed", labels: ["bug", "priority:high"] })],
});
// The actual issue carries the same applied labels, but lists them in the
// reverse order — which an order-independent set comparison must ignore.
const actual = repo({
labels: [
label({ name: "bug", color: "#d73a4a" }),
label({ name: "priority:high", color: "#b60205" }),
],
issues: [issue({ number: 1, state: "closed", labels: ["priority:high", "bug"] })],
});
expect(checkMutation(expected, actual)).toEqual({ pass: true });
});
it("passes when a pull request's reviews match as a set despite differing order", () => {
// The expected pull request #5 carries two reviews in one order.
const expected = repo({
pullRequests: [
pr({
number: 5,
state: "merged",
reviews: [
{ author: "alexion", kind: "comment", body: "first pass", comments: [] },
{ author: "alexion", kind: "approved", body: "looks good", comments: [] },
],
}),
],
});
// The actual pull request carries the same two reviews (each identified by
// author, kind, and body) but lists them in the reverse order — which an
// order-independent set comparison must ignore, as it already does for
// comments and labels.
const actual = repo({
pullRequests: [
pr({
number: 5,
state: "merged",
reviews: [
{ author: "alexion", kind: "approved", body: "looks good", comments: [] },
{ author: "alexion", kind: "comment", body: "first pass", comments: [] },
],
}),
],
});
expect(checkMutation(expected, actual)).toEqual({ pass: true });
});
});
describe("checkReadAnswer", () => {
it("passes when every required fact is present in the final report", () => {
// A read task requires the agent to report a count of open issues and a
// specific issue number; each fact lists acceptable renderings.
const facts: RequiredFact[] = [
{ description: "count of open issues", anyOf: ["3 open issues", "three open issues"] },
{ description: "the stale issue's number", anyOf: ["#42", "issue 42"] },
];
// The report plainly contains a rendering of each fact.
const report = "I found 3 open issues; the oldest untouched one is #42.";
expect(checkReadAnswer(facts, report)).toEqual({ pass: true });
});
it("fails and names the missing fact when a required fact is absent from the report", () => {
// A read task requires two facts, each named by a distinctive description.
const facts: RequiredFact[] = [
{ description: "the count of open bug issues", anyOf: ["2 open bug issues", "two open bug issues"] },
{ description: "the newest issue number", anyOf: ["#57", "issue 57"] },
];
// The report renders the first fact but omits any rendering of the second.
const report = "There are 2 open bug issues in the repository.";
const result = checkReadAnswer(facts, report);
expect(result.pass).toBe(false);
// The unmet fact must be identifiable by its own description so a failed read
// task can be diagnosed — not just "a required fact is missing".
if (result.pass === false) {
expect(result.differences.some((d) => d.includes("the newest issue number"))).toBe(true);
}
});
it("passes on an alternate anyOf rendering that differs in case and whitespace", () => {
// The fact offers two acceptable renderings of the same count.
const facts: RequiredFact[] = [
{ description: "count of open issues", anyOf: ["3 open issues", "three open issues"] },
];
// The report contains only the SECOND rendering, in different case and with
// irregular internal whitespace — which case- and whitespace-insensitive
// matching must still accept.
const report = "There are THREE Open Issues left.";
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", () => {
it("dispatches on the spec kind: full-state diff for mutations, answer-match for reads", () => {
// A mutation spec is scored by diffing the submitted repository state against
// the expected end state; a matching submission passes.
const expectedState = repo({
issues: [issue({ number: 1, state: "closed", labels: ["bug"] })],
labels: [label({ name: "bug", color: "#d73a4a" })],
});
const mutationSpec: ScoringSpec = { kind: "mutation", expected: expectedState };
const matchingSubmission: Submission = { kind: "mutation", state: expectedState };
expect(score(mutationSpec, matchingSubmission)).toEqual({ pass: true });
// A read spec is scored by matching required facts in the submitted report.
const readSpec: ScoringSpec = {
kind: "read",
facts: [{ description: "the answer", anyOf: ["42"] }],
};
const passingRead: Submission = { kind: "read", report: "the answer is 42" };
expect(score(readSpec, passingRead)).toEqual({ pass: true });
// And a read submission lacking the required fact fails.
const failingRead: Submission = { kind: "read", report: "no idea, sorry" };
expect(score(readSpec, failingRead).pass).toBe(false);
});
it("throws when the submission's kind does not match the spec's kind", () => {
// A mutation spec paired with a read submission is a caller error, not a
// scoreable outcome: score must reject it rather than silently score the
// wrong thing.
const mutationSpec: ScoringSpec = {
kind: "mutation",
expected: repo({ issues: [issue({ number: 1, state: "closed" })] }),
};
const readSubmission: Submission = { kind: "read", report: "" };
expect(() => score(mutationSpec, readSubmission)).toThrow();
});
});

304
bench/checker.ts Normal file
View File

@@ -0,0 +1,304 @@
// The checker: the pure scoring seam that turns a completed run into a
// deterministic pass/fail. A mutation task is scored by diffing the entire
// post-run repository state against the expected end state (so both the intended
// change and any collateral damage are caught); a read task is scored by matching
// its required answer facts against the agent's final report, with no LLM judge.
//
// The checker is fed synthetic state snapshots and expected states; capturing the
// live state from a real repository is the runner's job. The scoring-spec contract
// it consumes lives in scoring-spec.ts.
import type {
Comment,
Label,
PullRequest,
RepoState,
RequiredFact,
Review,
ScoringSpec,
} from "./scoring-spec.js";
/**
* The checker's verdict. On failure it carries the human-readable differences —
* each missing intended change or collateral change for a mutation, or each
* missing fact for a read — so a failed trial can be diagnosed from the record.
*/
export type CheckResult = { pass: true } | { pass: false; differences: string[] };
/** What a completed run submits for scoring, tagged by the kind of task it was. */
export type Submission =
| { kind: "mutation"; state: RepoState }
| { kind: "read"; report: string };
/**
* Score a mutation task by diffing the full actual state against the expected end
* state. The diff walks the whole snapshot, so a difference is raised whether the
* actual state is missing the intended change or carries a collateral one. The
* volatile, host-assigned ids and timestamps are simply never compared, so two
* states that agree on the meaningful fields match regardless of them.
*/
export function checkMutation(expected: RepoState, actual: RepoState): CheckResult {
const differences: string[] = [];
diffLabels(expected.labels, actual.labels, differences);
diffByNumber("issue", expected.issues, actual.issues, differences, diffConversation);
diffByNumber("pull request", expected.pullRequests, actual.pullRequests, differences, diffPullRequest);
return differences.length === 0 ? { pass: true } : { pass: false, differences };
}
/** Diff the repository's label definitions, matched by name. */
function diffLabels(expected: Label[], actual: Label[], differences: string[]): void {
const expectedByName = new Map(expected.map((l) => [l.name, l]));
const actualByName = new Map(actual.map((l) => [l.name, l]));
for (const [name, e] of expectedByName) {
const a = actualByName.get(name);
if (a === undefined) {
differences.push(`missing label "${name}"`);
continue;
}
if (e.color !== a.color) {
differences.push(`label "${name}" color expected "${e.color}" but was "${a.color}"`);
}
if ((e.description ?? "") !== (a.description ?? "")) {
differences.push(`label "${name}" description differs`);
}
}
for (const name of actualByName.keys()) {
if (!expectedByName.has(name)) {
differences.push(`unexpected label "${name}" (collateral change)`);
}
}
}
/**
* Match two lists of numbered entities (issues or pull requests) by their stable
* number, reporting any expected entity missing from the actual state and any
* actual entity the expected state does not contain (collateral), then comparing
* the fields of each matched pair.
*/
function diffByNumber<T extends { number: number }>(
kind: string,
expected: T[],
actual: T[],
differences: string[],
compareFields: (where: string, e: T, a: T, differences: string[]) => void,
): void {
const expectedByNumber = new Map(expected.map((e) => [e.number, e]));
const actualByNumber = new Map(actual.map((a) => [a.number, a]));
for (const [number, e] of expectedByNumber) {
const a = actualByNumber.get(number);
if (a === undefined) {
differences.push(`missing ${kind} #${number}`);
continue;
}
compareFields(`${kind} #${number}`, e, a, differences);
}
for (const number of actualByNumber.keys()) {
if (!expectedByNumber.has(number)) {
differences.push(`unexpected ${kind} #${number} (collateral change)`);
}
}
}
/**
* The conversation surface an issue and a pull request share: title, body, state,
* applied labels, assignees, and comments. State is compared as an opaque string
* so an issue's open/closed and a pull request's open/closed/merged both flow
* through the same diff.
*/
interface Conversation {
title: string;
body: string;
state: string;
labels: string[];
assignees: string[];
comments: Comment[];
}
/** Diff the conversation fields common to issues and pull requests. */
function diffConversation(where: string, e: Conversation, a: Conversation, differences: string[]): void {
diffScalar(where, "title", e.title, a.title, differences);
diffScalar(where, "body", e.body, a.body, differences);
diffScalar(where, "state", e.state, a.state, differences);
diffSet(where, "label", e.labels, a.labels, differences);
diffSet(where, "assignee", e.assignees, a.assignees, differences);
diffComments(where, e.comments, a.comments, differences);
}
/** Diff a pull request: its shared conversation surface plus its reviews. */
function diffPullRequest(where: string, e: PullRequest, a: PullRequest, differences: string[]): void {
diffConversation(where, e, a, differences);
diffReviews(where, e.reviews, a.reviews, differences);
}
function diffScalar(where: string, field: string, e: string, a: string, differences: string[]): void {
if (e !== a) {
differences.push(`${where} ${field} expected "${e}" but was "${a}"`);
}
}
/**
* Diff two order-independent sets of named things (applied labels, assignees),
* naming each element the expected state requires but the actual state lacks, and
* each the actual state carries but the expected state does not (collateral), so
* the divergence is diagnosable down to the specific label or assignee.
*/
function diffSet(where: string, noun: string, e: string[], a: string[], differences: string[]): void {
const { missing, extra } = matchByKey(a, e, (name) => name);
for (const name of missing) {
differences.push(`${where} missing ${noun} "${name}"`);
}
for (const name of extra) {
differences.push(`${where} unexpected ${noun} "${name}" (collateral change)`);
}
}
/**
* Diff two sets of comments, matched by author and body (volatile id and
* timestamp ignored) and compared order-independently. Each comment the expected
* state requires but the actual lacks, and each the actual carries but the
* expected does not (collateral), is named by its author and body.
*/
function diffComments(where: string, e: Comment[], a: Comment[], differences: string[]): void {
const { missing, extra } = matchByKey(a, e, commentKey);
for (const comment of missing) {
differences.push(`${where} missing comment ${describeComment(comment)}`);
}
for (const comment of extra) {
differences.push(`${where} unexpected comment ${describeComment(comment)} (collateral change)`);
}
}
/** The key a comment is matched by: its author and body, volatile fields dropped. */
function commentKey(comment: Comment): string {
return JSON.stringify([comment.author, comment.body]);
}
/** A readable rendering of the author and body a comment is matched by. */
function describeComment(comment: Comment): string {
return `from ${comment.author}: ${JSON.stringify(comment.body)}`;
}
/**
* Diff two sets of reviews, matched order-independently — like comments and
* labels — by author, kind, body, and their inline comments (themselves matched
* by author and body, order-independently). Each review the expected state
* requires but the actual lacks, and each collateral review the actual carries,
* is named by its author, kind, and body.
*/
function diffReviews(where: string, e: Review[], a: Review[], differences: string[]): void {
const { missing, extra } = matchByKey(a, e, reviewKey);
for (const review of missing) {
differences.push(`${where} missing review ${describeReview(review)}`);
}
for (const review of extra) {
differences.push(`${where} unexpected review ${describeReview(review)} (collateral change)`);
}
}
/** The key a review is matched by: author, kind, body, and its inline comments as a set. */
function reviewKey(review: Review): string {
const comments = review.comments.map((c) => [c.author, c.body]).sort();
return JSON.stringify([review.author, review.kind, review.body, comments]);
}
/** A readable rendering of the author, kind, and body a review is matched by. */
function describeReview(review: Review): string {
return `by ${review.author} (${review.kind}): ${JSON.stringify(review.body)}`;
}
/**
* Match two collections order-independently by a key, returning the expected
* items with no actual counterpart (`missing`) and the actual items with no
* expected counterpart (`extra`). Each expected item matches at most one actual
* item, so duplicates are honored (two identical expected comments require two
* in the actual state).
*/
function matchByKey<T>(
actual: T[],
expected: T[],
key: (item: T) => string,
): { missing: T[]; extra: T[] } {
const unmatched = expected.map((item) => ({ item, key: key(item) }));
const extra: T[] = [];
for (const item of actual) {
const k = key(item);
const index = unmatched.findIndex((candidate) => candidate.key === k);
if (index >= 0) {
unmatched.splice(index, 1);
} else {
extra.push(item);
}
}
return { missing: unmatched.map((entry) => entry.item), extra };
}
/**
* Score a read task by matching its required answer facts against the agent's
* final report — no LLM judge. A fact is present when the report contains any one
* of its acceptable renderings, compared after lower-casing and collapsing
* whitespace so trivial phrasing differences do not matter. The answer passes
* only when every required fact is present.
*/
export function checkReadAnswer(facts: RequiredFact[], report: string): CheckResult {
const haystack = normalizeText(report);
const missing = facts.filter((fact) => !factPresent(fact, haystack));
if (missing.length === 0) {
return { pass: true };
}
return {
pass: false,
differences: missing.map((fact) => `missing required fact: ${fact.description}`),
};
}
/**
* 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(/[*_`]/g, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Score a completed run against its scoring spec, dispatching on the task kind: a
* mutation spec is scored by the full-state diff against the submitted repository
* state, a read spec by the answer-match against the submitted report. The
* submission's kind must match the spec's — a mismatch is a caller error and
* throws, rather than silently scoring the wrong thing.
*/
export function score(spec: ScoringSpec, submission: Submission): CheckResult {
if (spec.kind === "mutation") {
if (submission.kind !== "mutation") {
throw new Error(
`a mutation spec must be scored against a mutation submission, got "${submission.kind}"`,
);
}
return checkMutation(spec.expected, submission.state);
}
if (submission.kind !== "read") {
throw new Error(
`a read spec must be scored against a read submission, got "${submission.kind}"`,
);
}
return checkReadAnswer(spec.facts, submission.report);
}

236
bench/guard.test.ts Normal file
View File

@@ -0,0 +1,236 @@
import { mkdtempSync, readdirSync, readlinkSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Arm } from "./result.js";
import { guardCommand, provisionArmBin } from "./guard.js";
/**
* Behavior: each shell-driving arm's own allow-listed binary passes the guard.
*
* The (arm, command) pairs below are independent literals — the benchmark spec
* and ADR 0016 fix which binary each arm drives — rather than values derived
* from the module under test, so the assertion stays a genuine check.
*/
const allowedForOwnBinary: ReadonlyArray<{ arm: Arm; command: string }> = [
{ arm: "gitea-axi", command: "gitea-axi issue list --state open" },
{ arm: "tea", command: "tea issues list --output json" },
{ arm: "raw-api", command: "curl -s https://host/api/v1/repos/o/r/issues" },
];
/**
* Behavior: a foreign binary — one belonging to a different arm — is denied.
*
* Only one binary is allow-listed per arm (ADR 0016), so driving another arm's
* tool must be refused. Each pair is an independent literal: a shell-driving
* arm paired with a command whose executable is a foreign binary.
*/
const foreignBinaryForArm: ReadonlyArray<{ arm: Arm; command: string; foreign: string }> = [
{ arm: "gitea-axi", command: "curl -s https://host/api/v1/repos", foreign: "curl" },
{ arm: "tea", command: "gitea-axi issue list", foreign: "gitea-axi" },
{ arm: "raw-api", command: "tea issues list --output json", foreign: "tea" },
];
/**
* Behavior: an absolute-path invocation of a foreign binary is denied.
*
* Naming a foreign binary by absolute path sidesteps the curated PATH, so the
* guard must still refuse it (ADR 0016). Each pair is an independent literal:
* a shell-driving arm paired with an absolute-path invocation of another arm's
* binary.
*/
const foreignAbsolutePathForArm: ReadonlyArray<{ arm: Arm; command: string }> = [
{ arm: "gitea-axi", command: "/usr/bin/curl -s https://host/api/v1/repos" },
{ arm: "tea", command: "/opt/bin/gitea-axi issue list" },
{ arm: "raw-api", command: "/usr/local/bin/tea issues list" },
];
/**
* Behavior: an interpreter-based fetch attempt is denied.
*
* Reaching the API over HTTP through a language runtime's HTTP client is a
* foreign path for every arm — including raw-api, whose only allowed binary is
* curl, not an interpreter (ADR 0016). Each pair is an independent literal: a
* shell-driving arm paired with an interpreter invocation that fetches over HTTP.
*/
const interpreterFetchForArm: ReadonlyArray<{ arm: Arm; command: string }> = [
{
arm: "gitea-axi",
command: `python3 -c "import urllib.request as u; u.urlopen('https://host/api/v1')"`,
},
{
arm: "tea",
command: `node -e "fetch('https://host/api/v1').then(r => r.text())"`,
},
{
arm: "raw-api",
command: `ruby -e "require 'net/http'; Net::HTTP.get(URI('https://host/api/v1'))"`,
},
];
describe("guardCommand", () => {
it.each(allowedForOwnBinary)(
"permits the $arm arm to run its own allow-listed binary",
({ arm, command }) => {
expect(guardCommand(arm, command)).toEqual({ allowed: true });
},
);
it.each(foreignBinaryForArm)(
"denies the $arm arm a command driving the foreign binary $foreign",
({ arm, command, foreign }) => {
const decision = guardCommand(arm, command);
expect(decision.allowed).toBe(false);
// A genuine foreign-binary denial names the offending binary in its reason,
// distinguishing it from denial for some unrelated cause.
if (decision.allowed === false) {
expect(decision.reason).toContain(foreign);
}
},
);
it("denies a foreign binary reached downstream of a pipe, not only the leading command", () => {
// The tea arm's own binary leads the line and is allow-listed, but a foreign
// interpreter (python3) is reached after the pipe. The guard must inspect
// every binary the command reaches, not just the first token.
const decision = guardCommand("tea", 'tea issues list | python3 -c "import urllib.request"');
expect(decision.allowed).toBe(false);
if (decision.allowed === false) {
expect(decision.reason).toContain("python3");
}
});
it.each(foreignAbsolutePathForArm)(
"denies the $arm arm a foreign binary named by absolute path",
({ arm, command }) => {
const decision = guardCommand(arm, command);
expect(decision.allowed).toBe(false);
},
);
it.each(interpreterFetchForArm)(
"denies the $arm arm an interpreter-based fetch over HTTP",
({ arm, command }) => {
const decision = guardCommand(arm, command);
expect(decision.allowed).toBe(false);
},
);
// The gitea-mcp arm reaches Gitea only through its attached MCP tools; its shell
// is disabled entirely, so no command — not even another arm's allow-listed
// binary or a bare harmless utility — may run (ADR 0016). Independent literals.
it.each([
"gitea-axi issue list",
"tea issues list",
"curl https://host",
"ls",
])("denies the gitea-mcp arm every shell command, including %j", (command) => {
const decision = guardCommand("gitea-mcp", command);
expect(decision.allowed).toBe(false);
if (decision.allowed === false) {
// The denial is about the shell being off for this arm, not an ordinary
// foreign-binary rejection.
expect(decision.reason).toMatch(/shell/i);
}
});
it("denies a foreign binary hidden inside a command substitution", () => {
// The leading curl is allow-listed for raw-api, but python3 hides inside the
// $(...) substitution. The guard must look inside substitutions, not just at
// the top-level command.
const decision = guardCommand(
"raw-api",
`curl -s $(python3 -c "print('https://host')")/api/v1/repos`,
);
expect(decision.allowed).toBe(false);
});
// The arm's own binary alongside curated harmless utilities (jq, head — which
// cannot reach the network or execute code) is permitted, and shell plumbing
// like a 2>&1 redirection must not be mistaken for a foreign command (ADR 0016).
it.each([
{ arm: "raw-api" as Arm, command: "curl -s https://host/api/v1/repos 2>&1 | head -n 5" },
{ arm: "tea" as Arm, command: "tea issues list --output json | jq '.[].number'" },
])(
"permits the $arm arm's binary piped through a curated read-only utility",
({ arm, command }) => {
expect(guardCommand(arm, command)).toEqual({ allowed: true });
},
);
it("permits a leading NAME=value assignment before the arm's binary", () => {
// The runner passes the API token via a leading environment assignment; the
// command is the binary that follows, not the assignment itself.
expect(guardCommand("gitea-axi", "TOKEN=secret gitea-axi issue list")).toEqual({
allowed: true,
});
});
it("denies a path-qualified invocation of the arm's own binary", () => {
// The curated PATH resolves the arm's binary by name; a path-qualified form
// sidesteps that and could resolve to something else via a symlink or copy,
// so it is refused even for the arm's own allow-listed binary.
const decision = guardCommand("tea", "/usr/bin/tea issues list");
expect(decision.allowed).toBe(false);
});
});
describe("provisionArmBin", () => {
let binDir: string;
beforeEach(() => {
binDir = mkdtempSync(join(tmpdir(), "gitea-axi-armbin-"));
});
afterEach(() => {
rmSync(binDir, { recursive: true, force: true });
});
// Fake resolver so the test never depends on binaries present on the host.
const locate = (binary: string) => `/fake/prefix/${binary}`;
// Independent literals: each shell arm's one allow-listed binary (ADR 0016),
// NOT read back from ARM_BINARY.
const shellArmBinary: ReadonlyArray<{ arm: Arm; binary: string }> = [
{ arm: "gitea-axi", binary: "gitea-axi" },
{ arm: "tea", binary: "tea" },
{ arm: "raw-api", binary: "curl" },
];
it.each(shellArmBinary)(
"exposes only the $arm arm's allow-listed binary $binary as a symlink",
({ arm, binary }) => {
provisionArmBin(arm, binDir, locate);
expect(readdirSync(binDir)).toEqual([binary]);
expect(readlinkSync(join(binDir, binary))).toBe(`/fake/prefix/${binary}`);
},
);
it("exposes nothing for the gitea-mcp arm, whose shell is disabled", () => {
provisionArmBin("gitea-mcp", binDir, locate);
expect(readdirSync(binDir)).toEqual([]);
});
it("throws when the arm's binary cannot be located", () => {
expect(() => provisionArmBin("tea", binDir, () => null)).toThrow(/tea/);
});
it("is idempotent across the trials of one sitting sharing a bin directory", () => {
// A benchmark sitting runs several trials against one per-sitting bin
// directory, so provisionArmBin is called once per trial on the same dir.
// A repeat call must not throw and must leave a single symlink, not a
// duplicate or a partially-clobbered link.
expect(() => {
provisionArmBin("gitea-axi", binDir, locate);
provisionArmBin("gitea-axi", binDir, locate);
}).not.toThrow();
expect(readdirSync(binDir)).toEqual(["gitea-axi"]);
expect(readlinkSync(join(binDir, "gitea-axi"))).toBe("/fake/prefix/gitea-axi");
});
});

327
bench/guard.ts Normal file
View File

@@ -0,0 +1,327 @@
import { accessSync, constants, mkdirSync, rmSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import type { Arm } from "./result.js";
/**
* The single binary each arm's agent is allowed to invoke through the shell.
* `null` marks an arm that runs with the shell disabled entirely (gitea-mcp,
* which reaches Gitea only through its attached MCP tools and so has no shell
* leakage surface at all).
*/
export const ARM_BINARY: Record<Arm, string | null> = {
"gitea-axi": "gitea-axi",
tea: "tea",
"gitea-mcp": null,
"raw-api": "curl",
};
/**
* The curated set of harmless utilities any arm may reach in addition to its own
* allow-listed binary. Every entry is a read/text/flow utility that cannot reach
* the network or execute arbitrary code. The set deliberately excludes anything
* that can launch another program or open a socket — language interpreters
* (python, node, ruby, perl, php, lua), shells (sh, bash, zsh), program-launching
* wrappers (env, xargs, find, timeout, nohup, nice), code-capable text tools
* (sed, awk), and every network tool (curl, wget, nc, ssh, git). Those are the
* evasion surface the guard exists to close, so none of them is "harmless".
*/
export const HARMLESS_BINARIES: ReadonlySet<string> = new Set([
"cat", "head", "tail", "wc", "cut", "tr", "sort", "uniq", "comm",
"grep", "egrep", "fgrep", "diff", "echo", "printf", "ls", "pwd", "cd",
"mkdir", "rmdir", "tee", "test", "[", "true", "false", "basename",
"dirname", "seq", "sleep", "date", "nl", "rev", "tac", "fold", "column",
"expr", "jq",
]);
/** The guard's verdict on one proposed shell command. */
export type GuardDecision = { allowed: true } | { allowed: false; reason: string };
/**
* The authoritative tool-isolation guard. Inspects a proposed shell command and
* permits it only if every binary it would reach is either the active arm's one
* allow-listed binary or a curated harmless utility. Foreign binaries,
* absolute-path evasions, and interpreter-based fetch tricks are denied.
*/
export function guardCommand(arm: Arm, command: string): GuardDecision {
const allowed = ARM_BINARY[arm];
if (allowed === null) {
return {
allowed: false,
reason: `the ${arm} arm runs with the shell disabled; only its MCP tools are available`,
};
}
const commands = extractCommands(command);
if (commands.length === 0) {
return { allowed: false, reason: "no command was found to run" };
}
for (const name of commands) {
if (name.includes("/")) {
const base = name.slice(name.lastIndexOf("/") + 1);
return {
allowed: false,
reason: `path-qualified command "${name}" is not permitted; invoke "${base}" by name so the ${arm} arm's curated PATH governs which binary resolves`,
};
}
if (name === allowed) {
continue;
}
if (HARMLESS_BINARIES.has(name)) {
continue;
}
return {
allowed: false,
reason: `"${name}" is not permitted for the ${arm} arm; only "${allowed}" and curated read-only utilities are allowed`,
};
}
return { allowed: true };
}
/** Whether `word` is a leading `NAME=value` environment assignment, not a command. */
function isAssignment(word: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
}
/**
* Extract every command name a shell command line would reach — across pipelines,
* sequences (`;`, `&&`, `||`), subshells, command substitutions (`$(...)` and
* backticks), and process substitutions. Leading `NAME=value` assignments and
* redirections (including forms like `2>&1`) are skipped so only genuine command
* names are returned. The guard checks every returned name, which is what stops a
* foreign binary from hiding downstream of a pipe or inside a substitution.
*/
function extractCommands(command: string): string[] {
const s = command;
const len = s.length;
const cur = { i: 0 };
const names: string[] = [];
const isSpace = (c: string | undefined): boolean => c === " " || c === "\t" || c === "\r";
const isWordBreak = (c: string | undefined): boolean =>
c === undefined ||
isSpace(c) ||
c === "\n" ||
c === "|" ||
c === "&" ||
c === ";" ||
c === "(" ||
c === ")" ||
c === "{" ||
c === "}" ||
c === "<" ||
c === ">" ||
c === "`";
function skipSpaces(): void {
while (cur.i < len && isSpace(s[cur.i])) cur.i++;
}
// Read one word starting at cur.i, honoring single/double quotes and backslash
// escapes, and recursing into any `$(...)` / backtick substitution embedded in
// a double-quoted span so its command names are captured too.
function readWord(): string {
let w = "";
while (cur.i < len) {
const c = s[cur.i];
if (c === "'") {
cur.i++;
while (cur.i < len && s[cur.i] !== "'") {
w += s[cur.i];
cur.i++;
}
cur.i++;
continue;
}
if (c === '"') {
cur.i++;
while (cur.i < len && s[cur.i] !== '"') {
if (s[cur.i] === "\\") {
w += s[cur.i + 1] ?? "";
cur.i += 2;
continue;
}
if (s[cur.i] === "$" && s[cur.i + 1] === "(") {
cur.i += 2;
parseSequence(")");
continue;
}
if (s[cur.i] === "`") {
cur.i++;
parseSequence("`");
continue;
}
w += s[cur.i];
cur.i++;
}
cur.i++;
continue;
}
if (c === "\\") {
w += s[cur.i + 1] ?? "";
cur.i += 2;
continue;
}
if (c === "$" && s[cur.i + 1] === "(") break;
if (isWordBreak(c)) break;
w += c;
cur.i++;
}
return w;
}
// Consume a redirection operator (cur.i is at `<` or `>`) and its target, so
// neither the operator nor the target file/descriptor is mistaken for a command.
function consumeRedirection(): void {
cur.i++; // the leading < or >
if (s[cur.i] === ">" || s[cur.i] === "<") cur.i++; // >>, <<, <>
if (s[cur.i] === "&") cur.i++; // >&, <& (duplicate a descriptor)
skipSpaces();
if (s[cur.i] === "-") {
cur.i++; // close a descriptor: >&-
return;
}
if (cur.i < len && s[cur.i] === "$" && s[cur.i + 1] === "(") {
cur.i += 2;
parseSequence(")");
return;
}
if (cur.i < len && !isWordBreak(s[cur.i])) {
readWord(); // discard the redirection target
}
}
// Parse a run of commands until end of input, or until `closer` (`)` for a
// subshell/substitution, `` ` `` for a backtick substitution) is reached.
function parseSequence(closer: string | null): void {
let expectCommand = true;
while (cur.i < len) {
skipSpaces();
const c = s[cur.i];
if (c === undefined) break;
if (closer !== null && c === closer) {
cur.i++;
return;
}
if (c === "\n" || c === ";") {
cur.i++;
expectCommand = true;
continue;
}
if (c === "&") {
if (s[cur.i + 1] === ">") {
cur.i++; // `&>` / `&>>` redirect both streams
consumeRedirection();
continue;
}
cur.i++;
if (s[cur.i] === "&") cur.i++; // && vs background &
expectCommand = true;
continue;
}
if (c === "|") {
cur.i++;
if (s[cur.i] === "|" || s[cur.i] === "&") cur.i++; // ||, |&
expectCommand = true;
continue;
}
if (c === "(") {
cur.i++;
parseSequence(")");
expectCommand = false;
continue;
}
if (c === "{") {
cur.i++;
expectCommand = true;
continue;
}
if (c === "}") {
cur.i++;
continue;
}
if (c === "`") {
if (closer === "`") {
cur.i++;
return;
}
cur.i++;
parseSequence("`");
expectCommand = false;
continue;
}
if (c === "$" && s[cur.i + 1] === "(") {
cur.i += 2;
parseSequence(")");
expectCommand = false;
continue;
}
if (c === "<" || c === ">") {
consumeRedirection();
continue;
}
const word = readWord();
if (/^\d+$/.test(word) && (s[cur.i] === "<" || s[cur.i] === ">")) {
consumeRedirection(); // a file-descriptor prefix, e.g. 2>&1
continue;
}
if (word.length === 0) continue;
if (expectCommand) {
if (isAssignment(word)) continue; // NAME=value prefix; the command follows
names.push(word);
expectCommand = false;
}
}
}
parseSequence(null);
return names;
}
/**
* Provision a curated per-arm bin directory that exposes only the arm's one
* allow-listed binary, as a convenience layer behind the authoritative guard.
*
* The directory is populated with a single symlink named after the arm's binary
* pointing at its resolved absolute path, so prepending the directory to PATH
* lets the arm's tool resolve by name while no foreign binary is reachable that
* way. The gitea-mcp arm has no shell binary, so its directory is left empty.
* `locate` resolves a binary name to an absolute path (defaulting to a search of
* the real PATH); a test injects a deterministic fake.
*/
export function provisionArmBin(
arm: Arm,
binDir: string,
locate: (binary: string) => string | null = locateOnPath,
): void {
mkdirSync(binDir, { recursive: true });
const binary = ARM_BINARY[arm];
if (binary === null) {
return; // gitea-mcp: the shell is disabled, so nothing is exposed.
}
const target = locate(binary);
if (target === null) {
throw new Error(
`cannot provision the ${arm} arm: its binary "${binary}" was not found on PATH`,
);
}
// Idempotent: the trials in one sitting share a single bin directory, so a prior
// trial may have already created this link. Remove any existing entry before
// re-linking rather than letting symlinkSync fail with EEXIST on the second trial.
const linkPath = join(binDir, binary);
rmSync(linkPath, { force: true });
symlinkSync(target, linkPath);
}
/** Resolve `binary` to the absolute path of the first executable of that name on PATH. */
function locateOnPath(binary: string): string | null {
for (const dir of (process.env.PATH ?? "").split(":")) {
if (dir === "") continue;
const candidate = join(dir, binary);
try {
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// Not here (or not executable); keep looking.
}
}
return null;
}

24
bench/host.ts Normal file
View File

@@ -0,0 +1,24 @@
// The live host adapter: the production `BenchHost` the runner drives against a
// real Gitea instance. It is a thin composition of the two live boundaries —
// seed.ts (provision, seed, delete) and snapshot.ts (capture) — bound to one set
// of host credentials. The runner depends only on the `BenchHost` seam, so this
// wiring is exercised by the smoke run rather than mocked unit tests, matching the
// seed tier.
import type { BenchHost } from "./runner.js";
import { captureRepoState } from "./snapshot.js";
import { deleteRepo, provisionRepo, seedRepo, type BenchAccess } from "./seed.js";
/**
* Build the live host bound to `access`: it provisions and seeds fresh throwaway
* repositories, captures their post-run state, and deletes them — all through the
* real Gitea API using gitea-axi's own credential discovery (see resolveBenchAccess).
*/
export function liveBenchHost(access: BenchAccess): BenchHost {
return {
provision: () => provisionRepo(access),
seed: (coords) => seedRepo(access, coords),
capture: (coords) => captureRepoState(access, coords),
delete: (coords) => deleteRepo(access, coords),
};
}

177
bench/report.test.ts Normal file
View File

@@ -0,0 +1,177 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { CliDeps } from "../src/deps.js";
import type { ResultRecord } from "./result.js";
import { createSampleStore, DEFAULT_STORE_ROOT } from "./store.js";
import { parseReportArgs, runReportCommand } from "./report.js";
/** A minimal passing gitea-axi ResultRecord, overridable per sample. */
function record(overrides: Partial<ResultRecord> = {}): ResultRecord {
return {
arm: "gitea-axi",
taskId: "t1",
tier: "read",
trial: 1,
timestamp: "2026-07-16T00:00:00Z",
tokens: { freshInput: 0, cacheCreation: 0, cacheRead: 0, output: 0 },
turns: 0,
durationMs: 0,
imputedCostUsd: 0,
outcome: { pass: true },
...overrides,
};
}
describe("parseReportArgs", () => {
// Behavior: with no arguments the parser resolves the documented defaults —
// the store root falls back to the module's DEFAULT_STORE_ROOT constant (the
// single source of truth for that default, so we assert against the imported
// constant rather than the hardcoded "bench/results" string, checking the
// parser wires the module default through on omission), and self-review
// defaults to permitted, the richer scored variant (independent literal true).
it("resolves the documented defaults when no arguments are given", () => {
const result = parseReportArgs([]);
expect(result.help).toBe(false);
if (result.help) return;
expect(result.storeRoot).toBe(DEFAULT_STORE_ROOT);
expect(result.selfReview).toBe(true);
});
// Behavior: the spaced value form of --store overrides the store root the report
// reads, and --no-self-review flips the self-review variant off. Expected values
// are independent literals: the store root the maintainer supplied and false.
it("overrides the store root via spaced --store and disables self-review via --no-self-review", () => {
const result = parseReportArgs(["--store", "/tmp/bench-out", "--no-self-review"]);
expect(result.help).toBe(false);
if (result.help) return;
expect(result.storeRoot).toBe("/tmp/bench-out");
expect(result.selfReview).toBe(false);
});
// Behavior: --store also accepts the inline --store=<dir> form, and --self-review
// states the default explicitly (permitted). Expected values are independent
// literals: the inline store root and true.
it("accepts the inline --store=<dir> form and honors an explicit --self-review", () => {
const result = parseReportArgs(["--store=/tmp/x", "--self-review"]);
expect(result.help).toBe(false);
if (result.help) return;
expect(result.storeRoot).toBe("/tmp/x");
expect(result.selfReview).toBe(true);
});
// Behavior: malformed input is rejected with a usage error — an unknown flag, a
// bare positional argument (not a --flag), a value flag (--store) missing its
// value, and a value handed to the boolean --self-review (which takes none).
it("rejects malformed input with a usage error", () => {
// Unknown flag.
expect(() => parseReportArgs(["--frobnicate", "x"])).toThrow();
// Bare positional argument, not a --flag.
expect(() => parseReportArgs(["positional"])).toThrow();
// Value flag missing its value.
expect(() => parseReportArgs(["--store"])).toThrow();
// The boolean --self-review takes no value.
expect(() => parseReportArgs(["--self-review=yes"])).toThrow();
});
// Behavior: --help and its -h alias short-circuit parsing to a help request,
// winning even alongside other arguments.
it("short-circuits to a help request for --help and -h, even alongside other args", () => {
expect(parseReportArgs(["--help"]).help).toBe(true);
expect(parseReportArgs(["-h"]).help).toBe(true);
expect(parseReportArgs(["--store", "/tmp/x", "--help"]).help).toBe(true);
});
});
describe("runReportCommand", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "bench-report-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
// Behavior: the offline reporting boundary opens the sample store at --store,
// drains it, aggregates against the scored suite and bonus, and prints the
// rendered comparison line-by-line through `out`, returning exit code 0. Given
// three passing gitea-axi samples on one read task — reaching the reporting
// floor of three — the rendered output labels the headline metric
// ("cost-equivalent", per the spec / ADR 0014), gives every arm a row (the
// four arms are gitea-axi, tea, gitea-mcp, raw-api), and annotates coverage
// against the "reporting floor of 3". Assertions are on the exit code and the
// presence of content, never on column layout, so they survive a rendering
// refactor. The command reads no credentials, host, or SDK, so deps are empty.
it("opens the store, aggregates, and prints the rendered comparison with exit code 0", async () => {
const store = createSampleStore(root);
store.append(record({ arm: "gitea-axi", taskId: "t1", tier: "read", trial: 1 }));
store.append(record({ arm: "gitea-axi", taskId: "t1", tier: "read", trial: 2 }));
store.append(record({ arm: "gitea-axi", taskId: "t1", tier: "read", trial: 3 }));
const deps: CliDeps = { env: {}, cwd: "/", globals: {} };
const lines: string[] = [];
const code = await runReportCommand(["--store", root], deps, (l) => lines.push(l));
const output = lines.join("\n");
// Success exit code.
expect(code).toBe(0);
// The headline metric is labelled.
expect(output.toLowerCase()).toContain("cost-equivalent");
// Every arm gets a row.
expect(output).toContain("gitea-axi");
expect(output).toContain("tea");
// Coverage is annotated against the reporting floor of three, which the
// three samples reach.
expect(output).toContain("reporting floor of 3");
});
// Behavior: an empty (never-written) store renders without error, inheriting
// the aggregator's placeholder behavior rather than special-casing it. The
// headline still labels the metric and every arm, and marks the unrun arms
// with the em-dash placeholder ("—", U+2014) rather than a misleading zero —
// the established renderReport behavior pinned by bench/aggregate.test.ts.
// Returns exit code 0. The `root` from beforeEach is created empty; nothing is
// appended.
it("renders an empty store without error, marking unrun arms with the em-dash placeholder", async () => {
const deps: CliDeps = { env: {}, cwd: "/", globals: {} };
const lines: string[] = [];
const code = await runReportCommand(["--store", root], deps, (l) => lines.push(l));
const output = lines.join("\n");
// Success exit code.
expect(code).toBe(0);
// The headline metric is labelled and every arm still appears.
expect(output.toLowerCase()).toContain("cost-equivalent");
expect(output).toContain("gitea-axi");
// An unrun arm shows the em-dash placeholder, never a zero.
expect(output).toContain("—");
});
// Behavior: --help prints the command's help and returns 0 without reading any
// store, so it works before a store exists. No store path is created or
// referenced. The help text names the command ("bench:report") — an
// independent literal.
it("prints help naming the command and returns 0 without reading a store", async () => {
const deps: CliDeps = { env: {}, cwd: "/", globals: {} };
const lines: string[] = [];
const code = await runReportCommand(["--help"], deps, (l) => lines.push(l));
const output = lines.join("\n");
expect(code).toBe(0);
expect(output).toContain("bench:report");
});
});

171
bench/report.ts Normal file
View File

@@ -0,0 +1,171 @@
// The maintainer-facing reporting command: render the accumulated sample store
// into the readable comparison.
//
// This is the reporting counterpart to run.ts. Where the run command spends the
// token budget on one cell, this command reads whatever samples have accumulated
// so far and prints the aggregator's comparison — headline, coverage, per-tier
// and per-token-component breakdowns, and the bonus table.
//
// Unlike run.ts, this command has no live boundary: it touches only the local
// sample store on disk (no credentials, host, or Agent SDK), so the whole command
// is deterministic and unit-tested. `parseReportArgs` is the pure argument seam.
import { pathToFileURL } from "node:url";
import type { CliDeps } from "../src/deps.js";
import { aggregate, readAllSamples, renderReport } from "./aggregate.js";
import { createSampleStore, DEFAULT_STORE_ROOT } from "./store.js";
import { buildBonusTasks, buildScoredSuite } from "./task-suite.js";
/** A fully-resolved report configuration. */
export interface ReportArgs {
/** The sample-store root to read; defaults to {@link DEFAULT_STORE_ROOT}. */
storeRoot: string;
/** Whether to render the suite/bonus variant for a self-review-permitting host. */
selfReview: boolean;
}
/** The parse outcome: a request for help, or a resolved configuration to render. */
export type ParsedReportArgs = { help: true } | ({ help: false } & ReportArgs);
/** The value-taking flags the command understands. */
const VALUE_FLAGS = new Set(["store"]);
/** The boolean flags the command understands, each with a `--no-` negation. */
const BOOLEAN_FLAGS = new Set(["self-review"]);
/** A usage error, surfaced to the maintainer with the offending detail. */
function usage(detail: string): Error {
return new Error(`${detail}\n\nUsage: bench:report [--store <dir>] [--self-review | --no-self-review]`);
}
/**
* Parse the report command's argv into a resolved configuration, applying
* defaults ({@link DEFAULT_STORE_ROOT} store, self-review permitted). A report
* needs no required selection, so no arguments is a valid invocation. Throws a
* usage error on an unknown flag, a bare argument, a value-flag missing its
* value, or a value handed to a boolean flag.
*/
export function parseReportArgs(argv: string[]): ParsedReportArgs {
let storeRoot = DEFAULT_STORE_ROOT;
let selfReview = true;
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index] as string;
if (token === "--help" || token === "-h") {
return { help: true };
}
if (!token.startsWith("--")) {
throw usage(`unexpected argument "${token}"`);
}
const equals = token.indexOf("=");
const rawName = equals === -1 ? token.slice(2) : token.slice(2, equals);
const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);
// A `--no-<flag>` prefix negates a boolean flag.
const negated = rawName.startsWith("no-");
const name = negated ? rawName.slice(3) : rawName;
if (BOOLEAN_FLAGS.has(name)) {
if (inlineValue !== undefined) {
throw usage(`flag --${rawName} takes no value`);
}
selfReview = !negated;
continue;
}
if (negated || !VALUE_FLAGS.has(name)) {
throw usage(`unknown flag "--${rawName}"`);
}
let value = inlineValue;
if (value === undefined) {
value = argv[index + 1];
if (value === undefined || value.startsWith("--")) {
throw usage(`flag --${name} needs a value`);
}
index += 1;
}
if (name === "store") {
storeRoot = value;
}
}
return { help: false, storeRoot, selfReview };
}
/** The help text printed for `--help` / `-h`. */
const HELP_TEXT = `bench:report — render the accumulated benchmark samples into a comparison.
Reads whatever samples have accumulated in the store and prints the aggregator's
comparison: the cost-equivalent-token headline, coverage annotated against the
reporting floor, per-tier and per-token-component breakdowns, and the bonus table.
Incomplete coverage is annotated rather than hidden, so a half-run matrix still
renders. This command is offline — it reads only the local store, never the host.
Usage:
npm run bench:report -- [options]
Options:
--store <dir> Sample store root to read (default: ${DEFAULT_STORE_ROOT})
--self-review Render the variant for a self-review-permitting host (default)
--no-self-review Render the variant for a host that forbids self-review
-h, --help Show this help
--self-review only affects the bonus capability catalog (whether the approve /
request-changes review pair appears there or in the scored suite); the scored
coverage is identical either way. Set it to match the host the samples were run on.`;
/**
* Render the accumulated sample store into the readable comparison. This is the
* command's boundary, but — unlike the run command — it is offline: it opens the
* local store at `--store`, drains it, aggregates against the scored suite and
* bonus definitions (resolved against the `--self-review` variant), and prints the
* rendered report through `out`. No orchestration, weighting, or rendering is
* reimplemented here; it drives the `readAllSamples` / `aggregate` / `renderReport`
* seam. Returns a process exit code.
*/
export async function runReportCommand(
argv: string[],
// Unused: an offline report needs no credentials, cwd, or env. Kept for signature
// parity with the command family (runBenchCommand takes the same (argv, deps, out)).
_deps: CliDeps,
out: (line: string) => void,
): Promise<number> {
const parsed = parseReportArgs(argv);
if (parsed.help) {
out(HELP_TEXT);
return 0;
}
const suiteOptions = { selfReviewPermitted: parsed.selfReview };
const store = createSampleStore(parsed.storeRoot);
const report = aggregate({
records: readAllSamples(store),
suite: buildScoredSuite(suiteOptions),
bonus: buildBonusTasks(suiteOptions),
});
out(renderReport(report));
return 0;
}
/** Entry point: render the report and set the process exit code. */
export async function main(): Promise<void> {
const deps: CliDeps = { env: process.env, cwd: process.cwd(), globals: {} };
try {
process.exitCode = await runReportCommand(
process.argv.slice(2),
deps,
(line) => process.stdout.write(`${line}\n`),
);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
// Run only when executed directly (e.g. `tsx bench/report.ts`), not when imported
// by a test. Under a TypeScript runner argv[1] is this file's own path.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

115
bench/result.ts Normal file
View File

@@ -0,0 +1,115 @@
// The immutable result-record shape that the whole benchmark harness reads and
// writes. One record captures one completed `(arm, task, trial)` run. Records
// are never mutated after they are written; deepening a cell's sample size
// appends new records rather than overwriting prior ones (see store.ts).
//
// The benchmark's own vocabulary (arm, cell, tier, cost-equivalent tokens) is
// documented in bench/README.md and the benchmark-harness spec, deliberately
// kept out of the tool's own domain glossary.
/** The four tool conditions the benchmark compares. */
export type Arm = "gitea-axi" | "tea" | "gitea-mcp" | "raw-api";
/**
* The task tiers the scored suite is weighted across. Views group by tier to
* show where an arm wins or loses.
*/
export type Tier = "read" | "single-mutation" | "find-then-act" | "multi-step";
/**
* The four token components retained per run. They are kept separate (rather
* than pre-summed) so cost-equivalent tokens can be re-weighted at render time
* without re-running — see the cost-equivalent-token-metric ADR. The auxiliary
* small model the runtime invokes for internal chores is folded into these
* counts, because it is real consumption against the same allowance.
*/
export interface TokenComponents {
/** Fresh, uncached input tokens (weighted 1x). */
freshInput: number;
/** Cache-creation (write) tokens. */
cacheCreation: number;
/** Cache-read tokens. */
cacheRead: number;
/** Output tokens. */
output: number;
}
/**
* Why a run failed. `incorrect` means the agent finished but the checker scored
* the outcome wrong; `confused` means it hit the turn cap; `hung` means it hit
* the wall-clock backstop. The confused-versus-hung split lets the reporting
* distinguish a lost agent from a stuck one.
*/
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.
*/
export interface ResultRecord {
/** The arm under test. */
arm: Arm;
/** The task's stable identifier. */
taskId: string;
/** The task's tier. */
tier: Tier;
/** The trial index within the cell (1-based). */
trial: number;
/** ISO 8601 timestamp of when the sample was recorded. */
timestamp: string;
/** The four token components. */
tokens: TokenComponents;
/** Number of agent turns the run took. */
turns: number;
/** Wall-clock duration in milliseconds. */
durationMs: number;
/** The runtime's imputed cost in US dollars, retained as a secondary metric. */
imputedCostUsd: number;
/** 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[];
}
/**
* The address of a cell in the sample store. A cell is one `(arm, task)` pair;
* its trials accumulate as samples within it.
*/
export interface CellKey {
arm: Arm;
taskId: string;
}

188
bench/run-loop.test.ts Normal file
View File

@@ -0,0 +1,188 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ResultRecord } from "./result.js";
import { runCells } from "./run-loop.js";
import type {
AgentDriver,
BenchHost,
RunBounds,
RunCellInput,
} from "./runner.js";
import type { BenchAccess } from "./seed.js";
import { createSampleStore } from "./store.js";
import { SAMPLE_TASK } from "./task.js";
// Trivial stubs for the collaborators the run loop merely forwards to the
// injected single-cell runner. Because runOne is faked below, none of these is
// ever touched, so bare casts are enough to satisfy the input shape.
const ACCESS: BenchAccess = { apiUrl: "https://git.example.test", token: "tok" };
const HOST = {} as BenchHost;
const DRIVER = {} as AgentDriver;
const BOUNDS: RunBounds = { turnCap: 10, wallClockMs: 60_000 };
const BUILD = { binRoot: "/nonexistent" };
/** A minimal recorded ResultRecord the fake runOne returns per trial. */
function record(overrides: Partial<ResultRecord> = {}): ResultRecord {
return {
arm: "gitea-axi",
taskId: SAMPLE_TASK.id,
tier: SAMPLE_TASK.tier,
trial: 1,
timestamp: "2026-07-16T00:00:00Z",
tokens: { freshInput: 1, cacheCreation: 0, cacheRead: 0, output: 1 },
turns: 1,
durationMs: 1,
imputedCostUsd: 0.01,
outcome: { pass: true },
...overrides,
};
}
describe("runCells", () => {
let storeRoot: string;
beforeEach(() => {
storeRoot = mkdtempSync(join(tmpdir(), "bench-run-loop-"));
});
afterEach(() => {
rmSync(storeRoot, { recursive: true, force: true });
});
// Behavior: with no trials count given, the run loop runs the selected
// (arm, task) cell for the default of five trials, invoking the injected
// single-cell runner once per trial with the cell's arm and task. The default
// of five is an independent literal fixed by the benchmark-harness spec / task
// 0029 ("Each cell defaults to five trials"), not recomputed from run-loop.ts.
it("runs the default of five trials when no trials count is given, once per trial with the cell's arm and task", async () => {
const store = createSampleStore(storeRoot);
const calls: RunCellInput[] = [];
const runOne = async (input: RunCellInput) => {
calls.push(input);
const rec = record({ trial: input.trial });
input.store.append(rec);
return { kind: "recorded", record: rec } as const;
};
await runCells({
arm: "gitea-axi",
task: SAMPLE_TASK,
access: ACCESS,
host: HOST,
driver: DRIVER,
store,
bounds: BOUNDS,
build: BUILD,
runOne,
});
// Exactly five invocations — the spec's default cell depth.
expect(calls).toHaveLength(5);
// Every invocation ran the selected cell's arm and task.
for (const call of calls) {
expect(call.arm).toBe("gitea-axi");
expect(call.task.id).toBe(SAMPLE_TASK.id);
}
});
// Behavior: re-running an already-sampled cell deepens it — the new trials are
// numbered past the highest trial the cell already holds and are appended, so
// prior samples are never overwritten (benchmark-harness spec / task 0029). A
// cell holding 2 samples at trials 1 and 2, run for 3 more trials, must end with
// 5 samples at trials [1,2,3,4,5]: the first two unchanged, three appended at
// 3, 4, 5. The trial sequence is an independent literal, not recomputed.
it("deepens an already-sampled cell, appending new trials past the highest without overwriting priors", async () => {
const store = createSampleStore(storeRoot);
// A prior sitting: two samples already accumulated in the cell.
const prior1 = record({ trial: 1 });
const prior2 = record({ trial: 2 });
store.append(prior1);
store.append(prior2);
const runOne = async (input: RunCellInput) => {
const rec = record({ trial: input.trial });
input.store.append(rec);
return { kind: "recorded", record: rec } as const;
};
const result = await runCells({
arm: "gitea-axi",
task: SAMPLE_TASK,
trials: 3,
access: ACCESS,
host: HOST,
driver: DRIVER,
store,
bounds: BOUNDS,
build: BUILD,
runOne,
});
const samples = store.read({ arm: "gitea-axi", taskId: SAMPLE_TASK.id });
// The cell deepened from 2 to 5 samples, numbered 1..5 in append order.
expect(samples).toHaveLength(5);
expect(samples.map((s) => s.trial)).toEqual([1, 2, 3, 4, 5]);
// The two prior samples were preserved byte-for-byte, not overwritten.
expect(samples[0]).toEqual(prior1);
expect(samples[1]).toEqual(prior2);
// The result reports how many samples the cell held before and after.
expect(result.priorSamples).toBe(2);
expect(result.totalSamples).toBe(5);
});
// Behavior: an attempt the single-cell runner flags invalid produces no sample
// and is tallied separately; a cell only meets the reporting floor once it holds
// at least three samples (benchmark-harness spec / task 0029, "the reporting
// floor of three"). Here 2 of 5 attempts record and 3 are flagged invalid (a
// foreign tool was reached), so the store gains only the 2 recorded samples, the
// invalid count is tracked apart, and 2 < 3 leaves the cell below the floor. The
// literals 2, 3, and false come from this worked example, not from run-loop.ts.
it("tallies invalid attempts apart from recorded samples and stays below the reporting floor at two samples", async () => {
const store = createSampleStore(storeRoot);
// Record on the first two attempts, flag the rest invalid without appending.
let call = 0;
const runOne = async (input: RunCellInput) => {
call += 1;
if (call <= 2) {
const rec = record({ trial: input.trial });
input.store.append(rec);
return { kind: "recorded", record: rec } as const;
}
const leaks: string[] = ["curl"];
return { kind: "invalid" as const, leaks };
};
const result = await runCells({
arm: "gitea-axi",
task: SAMPLE_TASK,
trials: 5,
access: ACCESS,
host: HOST,
driver: DRIVER,
store,
bounds: BOUNDS,
build: BUILD,
runOne,
});
// Recorded and invalid attempts are tallied separately.
expect(result.recorded).toBe(2);
expect(result.invalid).toBe(3);
// Only the two recorded attempts became samples; invalid attempts left none.
expect(result.totalSamples).toBe(2);
expect(store.read({ arm: "gitea-axi", taskId: SAMPLE_TASK.id })).toHaveLength(2);
// Two samples is below the reporting floor of three.
expect(result.meetsFloor).toBe(false);
});
});

109
bench/run-loop.ts Normal file
View File

@@ -0,0 +1,109 @@
// The run loop: the maintainer-facing orchestration that runs one chosen
// `(arm, task)` cell for a batch of trials on demand, so only the token budget
// available at that moment is spent. It drives the single-cell runner (runner.ts)
// and the append-only sample store (store.ts) built in earlier slices rather than
// reimplementing any orchestration — its whole job is to decide how many trials to
// run and at what trial numbers, then leave provisioning, running, scoring, and
// appending to `runCell`.
//
// Because results are immutable timestamped samples, running a cell that already
// has samples deepens it: the new trials continue past the highest trial the cell
// holds and append, so a cell's sample size can be grown opportunistically across
// separate sittings. Each cell defaults to five trials with a reporting floor of
// three; the loop reports whether the cell now meets that floor.
import type { BuildArmOptions } from "./arm.js";
import type { Arm } from "./result.js";
import { runCell, type CellOutcome, type RunBounds, type RunCellInput, type RunnerClock } from "./runner.js";
import type { BenchAccess } from "./seed.js";
import type { SampleStore } from "./store.js";
import type { BenchTask } from "./task.js";
/** A cell defaults to five trials per sitting. */
export const DEFAULT_TRIALS = 5;
/** A cell is only reported once it holds at least this many samples. */
export const REPORTING_FLOOR = 3;
/** Everything needed to run a batch of trials for one `(arm, task)` cell. */
export interface RunCellsInput {
arm: Arm;
task: BenchTask;
/** Trials to run this sitting; defaults to {@link DEFAULT_TRIALS}. */
trials?: number;
access: BenchAccess;
host: RunCellInput["host"];
driver: RunCellInput["driver"];
store: SampleStore;
bounds: RunBounds;
build: BuildArmOptions;
clock?: Partial<RunnerClock>;
/** The single-cell runner; injectable for tests. Defaults to {@link runCell}. */
runOne?: (input: RunCellInput) => Promise<CellOutcome>;
}
/** The tally of running one batch of trials for a cell. */
export interface RunCellsResult {
arm: Arm;
taskId: string;
/** Per-attempt outcomes in run order. */
outcomes: CellOutcome[];
/** Attempts that produced a scored sample this sitting. */
recorded: number;
/** Attempts flagged invalid (a foreign tool was reached) this sitting; not sampled. */
invalid: number;
/** Samples the cell held before this sitting. */
priorSamples: number;
/** Samples the cell holds after this sitting. */
totalSamples: number;
/** Whether the cell now meets the reporting floor of {@link REPORTING_FLOOR} samples. */
meetsFloor: boolean;
}
/**
* Run a batch of trials for one cell. Deepens the cell if it already has samples:
* trial numbering continues past the highest existing trial, and every scored
* sample is appended by `runCell` rather than overwriting a slot.
*/
export async function runCells(input: RunCellsInput): Promise<RunCellsResult> {
const { arm, task, access, host, driver, store, bounds, build, clock } = input;
const runOne = input.runOne ?? runCell;
const trials = input.trials ?? DEFAULT_TRIALS;
const cell = { arm, taskId: task.id };
const prior = store.read(cell);
// Continue numbering past the highest trial the cell already holds so a
// deepening sitting never reuses a trial number, even if earlier attempts were
// flagged invalid and left gaps (an invalid attempt records no sample).
const highestTrial = prior.reduce((max, sample) => Math.max(max, sample.trial), 0);
const outcomes: CellOutcome[] = [];
for (let offset = 0; offset < trials; offset += 1) {
outcomes.push(
await runOne({
arm,
task,
trial: highestTrial + offset + 1,
access,
host,
driver,
store,
bounds,
build,
clock,
}),
);
}
const totalSamples = store.read(cell).length;
return {
arm,
taskId: task.id,
outcomes,
recorded: outcomes.filter((outcome) => outcome.kind === "recorded").length,
invalid: outcomes.filter((outcome) => outcome.kind === "invalid").length,
priorSamples: prior.length,
totalSamples,
meetsFloor: totalSamples >= REPORTING_FLOOR,
};
}

121
bench/run.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_STORE_ROOT,
DEFAULT_TURN_CAP,
DEFAULT_WALL_CLOCK_MS,
parseRunArgs,
} from "./run.js";
describe("parseRunArgs", () => {
// Behavior: parsing the required --arm and --task selection yields the resolved
// cell with defaults applied for everything else — five trials (spec: "Each cell
// defaults to five trials"), the documented turn-cap and wall-clock backstop, the
// default store root, and the login taken from the environment when --login is
// omitted. Trials is the independent literal 5; the other defaults are asserted
// against the module's documented default constants (the single source of truth
// for each default), which checks the parser wires them through on omission.
it("applies the documented defaults when only the required arm and task are given, taking the login from the environment", () => {
const result = parseRunArgs(
["--arm", "gitea-axi", "--task", "close-csv-export-issue"],
{ GITEA_AXI_BENCH_LOGIN: "alexion" },
);
expect(result.help).toBe(false);
if (result.help) return;
// The required selection resolves to the chosen cell.
expect(result.arm).toBe("gitea-axi");
expect(result.taskId).toBe("close-csv-export-issue");
// Trials default to five (independent literal from the spec).
expect(result.trials).toBe(5);
// The remaining bounds and store root fall back to the documented defaults.
expect(result.turnCap).toBe(DEFAULT_TURN_CAP);
expect(result.wallClockMs).toBe(DEFAULT_WALL_CLOCK_MS);
expect(result.storeRoot).toBe(DEFAULT_STORE_ROOT);
// The login comes from the environment.
expect(result.login).toBe("alexion");
});
// Behavior: every optional flag overrides its default, and an explicit --login
// takes precedence over the environment. The parser passes through whatever the
// maintainer supplies. All expected values are independent literals chosen apart
// from the code; login must be the explicit "explicit-login" even though the
// environment also sets GITEA_AXI_BENCH_LOGIN ("env-login").
it("passes every supplied flag through, with an explicit login overriding the environment", () => {
const result = parseRunArgs(
[
"--arm",
"tea",
"--task",
"read-open-issue-count",
"--trials",
"3",
"--turn-cap",
"12",
"--wall-clock-ms",
"90000",
"--store",
"/tmp/bench-out",
"--login",
"explicit-login",
],
{ GITEA_AXI_BENCH_LOGIN: "env-login" },
);
expect(result.help).toBe(false);
if (result.help) return;
expect(result.arm).toBe("tea");
expect(result.taskId).toBe("read-open-issue-count");
expect(result.trials).toBe(3);
expect(result.turnCap).toBe(12);
expect(result.wallClockMs).toBe(90000);
expect(result.storeRoot).toBe("/tmp/bench-out");
// The explicit --login beats the env-provided login.
expect(result.login).toBe("explicit-login");
});
// Behavior: malformed or incomplete input is rejected with a usage error. The
// four arms are exactly gitea-axi, tea, gitea-mcp, raw-api; --arm and --task are
// required; --trials must be a positive integer; unknown flags are not accepted;
// and a login must be resolvable (from --login or the environment). A non-empty
// env login is supplied where the tested defect is elsewhere, so the throw is the
// intended one rather than a missing login.
it("rejects malformed or incomplete input with a usage error", () => {
const env = { GITEA_AXI_BENCH_LOGIN: "alexion" };
// Unknown arm (not one of the four).
expect(() => parseRunArgs(["--arm", "github", "--task", "t"], env)).toThrow();
// Missing required --arm.
expect(() => parseRunArgs(["--task", "t"], env)).toThrow();
// Missing required --task.
expect(() => parseRunArgs(["--arm", "tea"], env)).toThrow();
// Non-numeric trials.
expect(() =>
parseRunArgs(["--arm", "tea", "--task", "t", "--trials", "abc"], env),
).toThrow();
// Unknown flag.
expect(() =>
parseRunArgs(["--arm", "tea", "--task", "t", "--frobnicate", "x"], env),
).toThrow();
// The removed --model flag is now unknown and rejected.
expect(() =>
parseRunArgs(["--arm", "tea", "--task", "t", "--model", "x"], env),
).toThrow();
// Login is required and here is resolvable from neither --login nor the env.
expect(() => parseRunArgs(["--arm", "tea", "--task", "t"], {})).toThrow();
});
// Behavior: --help short-circuits parsing and reports a help request, winning
// even alongside other arguments and via the -h alias.
it("short-circuits to a help request for --help and -h, even alongside other args", () => {
expect(parseRunArgs(["--help"], {}).help).toBe(true);
expect(parseRunArgs(["-h"], {}).help).toBe(true);
expect(parseRunArgs(["--arm", "tea", "--help"], {}).help).toBe(true);
});
});

284
bench/run.ts Normal file
View File

@@ -0,0 +1,284 @@
// The maintainer-facing run-loop command: run one chosen benchmark cell on demand.
//
// This is the entry point the maintainer invokes to spend the token budget
// available at a given moment on exactly one `(arm, task)` cell. It parses the
// selection, resolves live host access through gitea-axi's own credential path,
// resolves the scored suite against the host's self-review support, and drives the
// run loop (run-loop.ts) — which in turn drives the single-cell runner and the
// append-only sample store built in earlier slices. No orchestration is
// reimplemented here.
//
// The command is bench-internal (bench/ is excluded from the published package)
// and is executed with a TypeScript-aware runner; see `npm run bench:run`.
//
// The argument parser (`parseRunArgs`) is the pure, unit-tested seam. The live
// wiring in `runBenchCommand` is a live boundary — it resolves real credentials
// and drives the real host and Agent SDK — so, like the seed and runner smoke
// tiers, it is validated by running it rather than by mocked unit tests.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { CliDeps } from "../src/deps.js";
import { liveBenchHost } from "./host.js";
import type { Arm } from "./result.js";
import { DEFAULT_TRIALS, REPORTING_FLOOR, runCells, type RunCellsResult } from "./run-loop.js";
import { resolveBenchAccess } from "./seed.js";
import { detectSelfReviewSupport } from "./self-review.js";
import { sdkAgentDriver } from "./sdk-driver.js";
import { createSampleStore, DEFAULT_STORE_ROOT } from "./store.js";
import { buildScoredSuite } from "./task-suite.js";
// Re-exported so this command's existing importers keep resolving it from here;
// its single source of truth is now the store, which owns the default root.
export { DEFAULT_STORE_ROOT };
/** The default turn cap a run is bounded by when not overridden. */
export const DEFAULT_TURN_CAP = 40;
/** The default wall-clock backstop (ms) a run is bounded by when not overridden. */
export const DEFAULT_WALL_CLOCK_MS = 300_000;
/** Environment variable naming the tea login the benchmark authenticates through. */
export const LOGIN_ENV = "GITEA_AXI_BENCH_LOGIN";
/** The four arms a cell may select. */
export const ARMS: readonly Arm[] = ["gitea-axi", "tea", "gitea-mcp", "raw-api"];
/** A fully-resolved cell selection and run configuration. */
export interface RunArgs {
arm: Arm;
taskId: string;
/** Trials to run this sitting; defaults to {@link DEFAULT_TRIALS}. */
trials: number;
/** The tea login the benchmark authenticates through. */
login: string;
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. */
export type ParsedRunArgs = { help: true } | ({ help: false } & RunArgs);
/** The value-taking flags the command understands; anything else is rejected. */
const KNOWN_FLAGS = new Set([
"arm",
"task",
"trials",
"login",
"turn-cap",
"wall-clock-ms",
"store",
"skill",
]);
/** A usage error, surfaced to the maintainer with the offending detail. */
function usage(detail: string): Error {
return new Error(`${detail}\n\nUsage: bench:run --arm <arm> --task <task-id> [--login <name>] [--trials <n>]`);
}
/** Parse a flag's value as a positive integer, rejecting anything else. */
function positiveInt(value: string, flag: string): number {
if (!/^\d+$/.test(value) || Number(value) < 1) {
throw usage(`--${flag} must be a positive integer, got "${value}"`);
}
return Number(value);
}
/**
* Parse the run-loop command's argv into a resolved configuration, applying
* defaults ({@link DEFAULT_TRIALS} trials, {@link DEFAULT_TURN_CAP} turn cap,
* {@link DEFAULT_WALL_CLOCK_MS} backstop, {@link DEFAULT_STORE_ROOT} store, and the
* login from {@link LOGIN_ENV}). Throws a usage error when a required selection is
* missing or a value is malformed.
*/
export function parseRunArgs(
argv: string[],
env: Record<string, string | undefined>,
): ParsedRunArgs {
const flags = new Map<string, string>();
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index] as string;
if (token === "--help" || token === "-h") {
return { help: true };
}
if (!token.startsWith("--")) {
throw usage(`unexpected argument "${token}"`);
}
const equals = token.indexOf("=");
const name = equals === -1 ? token.slice(2) : token.slice(2, equals);
if (!KNOWN_FLAGS.has(name)) {
throw usage(`unknown flag "--${name}"`);
}
let value: string | undefined;
if (equals === -1) {
value = argv[index + 1];
if (value === undefined || value.startsWith("--")) {
throw usage(`flag --${name} needs a value`);
}
index += 1;
} else {
value = token.slice(equals + 1);
}
flags.set(name, value);
}
const arm = flags.get("arm");
if (arm === undefined) {
throw usage("--arm <arm> is required");
}
if (!ARMS.includes(arm as Arm)) {
throw usage(`--arm must be one of ${ARMS.join(", ")}, got "${arm}"`);
}
const taskId = flags.get("task");
if (taskId === undefined) {
throw usage("--task <task-id> is required");
}
const login = flags.get("login") ?? env[LOGIN_ENV];
if (login === undefined || login.length === 0) {
throw usage(`--login <name> is required (or set ${LOGIN_ENV})`);
}
const trials = flags.has("trials") ? positiveInt(flags.get("trials") as string, "trials") : DEFAULT_TRIALS;
const turnCap = flags.has("turn-cap")
? positiveInt(flags.get("turn-cap") as string, "turn-cap")
: DEFAULT_TURN_CAP;
const wallClockMs = flags.has("wall-clock-ms")
? positiveInt(flags.get("wall-clock-ms") as string, "wall-clock-ms")
: DEFAULT_WALL_CLOCK_MS;
return {
help: false,
arm: arm as Arm,
taskId,
trials,
login,
turnCap,
wallClockMs,
storeRoot: flags.get("store") ?? DEFAULT_STORE_ROOT,
...(flags.has("skill") ? { skillPath: flags.get("skill") } : {}),
};
}
/** The help text printed for `--help` / `-h`. */
const HELP_TEXT = `bench:run — run one benchmark cell on demand.
Runs a single (arm, task) cell for a batch of trials against the live Gitea host,
appending each scored sample to the store. Re-running a cell deepens it: new trials
append rather than overwrite, so a cell's sample size can be grown across sittings.
Usage:
npm run bench:run -- --arm <arm> --task <task-id> [options]
Required:
--arm <arm> One of: ${ARMS.join(", ")}
--task <task-id> A scored-suite task id (an unknown id prints the available ids)
Options:
--login <name> tea login to authenticate through (default: $${LOGIN_ENV})
--trials <n> Trials to run this sitting (default: ${DEFAULT_TRIALS})
--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. */
function summarize(result: RunCellsResult, storeRoot: string): string[] {
const floorNote = result.meetsFloor
? `meets the reporting floor of ${REPORTING_FLOOR}`
: `below the reporting floor of ${REPORTING_FLOOR} — deepen this cell before reporting`;
return [
`Cell (${result.arm}, ${result.taskId}): ${result.recorded} recorded, ${result.invalid} invalid this sitting.`,
`Samples: ${result.priorSamples}${result.totalSamples} (${floorNote}).`,
`Store: ${storeRoot}`,
];
}
/**
* Run one chosen cell on demand: resolve live host access, resolve the scored
* suite against the host's self-review support, select the task, and drive the run
* loop. This is the command's live boundary — it authenticates and drives the real
* host and Agent SDK — so it is validated by running it, not by mocked unit tests
* (the pure `parseRunArgs` seam is the unit-tested part). Returns a process exit
* code and prints progress and the final tally through `out`.
*/
export async function runBenchCommand(
argv: string[],
deps: CliDeps,
out: (line: string) => void,
): Promise<number> {
const parsed = parseRunArgs(argv, deps.env);
if (parsed.help) {
out(HELP_TEXT);
return 0;
}
const access = await resolveBenchAccess(deps, parsed.login);
// The two review tasks are approve/request-changes or comment reviews depending
// on what the host permits, so the suite is resolved against a live probe once
// before selecting the task (see task-suite.ts and self-review.ts).
out(`Probing self-review support on ${new URL(access.apiUrl).host}`);
const selfReviewPermitted = await detectSelfReviewSupport(access);
const suite = buildScoredSuite({ selfReviewPermitted });
const task = suite.find((candidate) => candidate.id === parsed.taskId);
if (task === undefined) {
out(`No scored task with id "${parsed.taskId}". Available task ids:`);
for (const candidate of suite) {
out(` ${candidate.id} (${candidate.tier})`);
}
return 1;
}
const store = createSampleStore(parsed.storeRoot);
const binRoot = mkdtempSync(join(tmpdir(), "bench-run-bin-"));
out(`Running ${parsed.trials} trial(s) of cell (${parsed.arm}, ${task.id})…`);
try {
const result = await runCells({
arm: parsed.arm,
task,
trials: parsed.trials,
access,
host: liveBenchHost(access),
// Every arm runs on the driver's single fixed model per the spec, so the
// comparison measures the tool rather than the model; the command exposes
// no per-cell model override that could break that invariant.
driver: sdkAgentDriver(),
store,
bounds: { turnCap: parsed.turnCap, wallClockMs: parsed.wallClockMs },
build: { binRoot, ...(parsed.skillPath !== undefined ? { skillPath: parsed.skillPath } : {}) },
});
for (const line of summarize(result, parsed.storeRoot)) {
out(line);
}
return 0;
} finally {
rmSync(binRoot, { recursive: true, force: true });
}
}
/** Entry point: parse argv, run the command, and set the process exit code. */
export async function main(): Promise<void> {
const deps: CliDeps = { env: process.env, cwd: process.cwd(), globals: {} };
try {
process.exitCode = await runBenchCommand(
process.argv.slice(2),
deps,
(line) => process.stdout.write(`${line}\n`),
);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
// Run only when executed directly (e.g. `tsx bench/run.ts`), not when imported by
// a test. Under a TypeScript runner argv[1] is this file's own path.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

111
bench/runner.smoke.test.ts Normal file
View File

@@ -0,0 +1,111 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { CliDeps } from "../src/deps.js";
import { liveBenchHost } from "./host.js";
import { runCell } from "./runner.js";
import { resolveBenchAccess, type BenchAccess } from "./seed.js";
import { sdkAgentDriver } from "./sdk-driver.js";
import { createSampleStore } from "./store.js";
import { SAMPLE_TASK } from "./task.js";
/**
* The single-cell runner smoke tier: one live run of the whole tracer-bullet path
* against a real Gitea host, driving the agent through the Claude Agent SDK. It
* proves that seed, arm scaffolding, guard, runner, checker, and store all connect
* end to end — provisioning and seeding a fresh repository, running the sample task
* under the gitea-axi arm, capturing and scoring the result, appending the sample,
* and deleting the repository.
*
* Like the seed smoke tier it keys off GITEA_AXI_BENCH_LOGIN (the live host,
* discovered through gitea-axi's tea-login credential path) and skips cleanly when
* that is unset. It additionally skips when the Agent SDK is not installed, since
* the SDK is an optional peer of the harness needed only for live runs — either
* way a skip counts as a pass, matching the end-to-end tier's behaviour when no
* live instance is configured. Running it for real also requires the `gitea-axi`
* CLI on PATH (the arm's allow-listed binary) and a Claude subscription.
*
* The run's pass/fail is nondeterministic because a live model drives it, so the
* assertions are structural — the terminal outcome shape and the record and
* lifecycle facts — never a fixed pass/fail.
*/
const login = process.env.GITEA_AXI_BENCH_LOGIN;
// The Agent SDK is loaded through a computed specifier so this file type-checks and
// the deterministic tier runs without the package present; here we probe once
// whether it is installed so the tier skips rather than errors when it is absent.
const SDK_MODULE = "@anthropic-ai/claude-agent-sdk";
let sdkAvailable = false;
try {
await import(SDK_MODULE);
sdkAvailable = true;
} catch {
sdkAvailable = false;
}
describe.skipIf(!login || !sdkAvailable)("single-cell runner smoke", () => {
let access: BenchAccess;
let binRoot: string;
let storeRoot: string;
beforeAll(async () => {
const deps: CliDeps = {
env: process.env,
cwd: process.cwd(),
globals: { login },
};
access = await resolveBenchAccess(deps, login!);
binRoot = mkdtempSync(join(tmpdir(), "bench-runner-smoke-bin-"));
storeRoot = mkdtempSync(join(tmpdir(), "bench-runner-smoke-store-"));
}, 180_000);
afterAll(() => {
if (binRoot) rmSync(binRoot, { recursive: true, force: true });
if (storeRoot) rmSync(storeRoot, { recursive: true, force: true });
});
it(
"runs one sample cell end to end against the live host, recording a scored or invalid result",
async () => {
const store = createSampleStore(storeRoot);
const outcome = await runCell({
arm: "gitea-axi",
task: SAMPLE_TASK,
trial: 1,
access,
host: liveBenchHost(access),
driver: sdkAgentDriver(),
store,
// The runner's own wall-clock backstop bounds the run well within the
// per-test timeout below.
bounds: { turnCap: 40, wallClockMs: 300_000 },
build: { binRoot },
});
// The full path completed: the cell was either scored (recorded) or the
// audit flagged a leak (invalid) — both are legitimate terminal outcomes,
// and either way the throwaway repository was deleted in runCell's finally.
expect(["recorded", "invalid"]).toContain(outcome.kind);
if (outcome.kind === "recorded") {
const samples = store.read({ arm: "gitea-axi", taskId: SAMPLE_TASK.id });
expect(samples).toHaveLength(1);
expect(outcome.record.taskId).toBe(SAMPLE_TASK.id);
expect(outcome.record.tier).toBe(SAMPLE_TASK.tier);
// The four token components and the imputed cost were captured.
expect(outcome.record.tokens).toEqual(
expect.objectContaining({
freshInput: expect.any(Number),
cacheCreation: expect.any(Number),
cacheRead: expect.any(Number),
output: expect.any(Number),
}),
);
expect(typeof outcome.record.imputedCostUsd).toBe("number");
}
},
360_000,
);
});

513
bench/runner.test.ts Normal file
View File

@@ -0,0 +1,513 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
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";
// The user the fake seed/capture are parametrized by; a single independent
// literal, echoed into the passing capture below.
const USER = "benchbot";
// The fixed coordinates the fake host provisions. Independent literals so the
// delete-with-these-coords assertion is unambiguous.
const COORDS: RepoCoords = { owner: USER, repo: "bench-1" };
const ACCESS: BenchAccess = { apiUrl: "https://git.example.test", token: "tok" };
// The passing post-run state IS the task's own expected end state, so the
// checker scores it a legitimate pass. Built from the task, not from runner.ts.
const spec = SAMPLE_TASK.scoringSpec(USER);
const passingState: RepoState = spec.kind === "mutation" ? spec.expected : groundTruth(USER);
// The four token components, turns, and imputed cost are independent literals
// planted in the fake driver; the recorded sample must carry them back unchanged.
const DRIVER_TOKENS = { freshInput: 100, cacheCreation: 20, cacheRead: 300, output: 40 };
const DRIVER_TURNS = 5;
const DRIVER_COST = 0.12;
/** A fake host recording which methods were called and with what coords. */
function createFakeHost() {
const calls = {
provisioned: false,
seeded: false,
captured: false,
deletedCoords: null as RepoCoords | null,
};
const host: BenchHost = {
async provision() {
calls.provisioned = true;
return COORDS;
},
async seed(coords) {
calls.seeded = true;
return groundTruth(coords.owner);
},
async capture() {
calls.captured = true;
return passingState;
},
async delete(coords) {
calls.deletedCoords = coords;
},
};
return { host, calls };
}
/**
* A fake host whose `capture` returns the UNMUTATED seed — the target issue "Add
* CSV export option" is still OPEN, so the task's spec (which expects it CLOSED)
* is not satisfied and the checker scores the run incorrect. Everything else
* matches createFakeHost. Records the deleted coords for the cleanup assertion.
*/
function createUnmutatedHost() {
const calls = { deletedCoords: null as RepoCoords | null };
const host: BenchHost = {
async provision() {
return COORDS;
},
async seed(coords) {
return groundTruth(coords.owner);
},
async capture(coords) {
return groundTruth(coords.owner);
},
async delete(coords) {
calls.deletedCoords = coords;
},
};
return { host, calls };
}
/** A fake driver that resolves immediately with the planted metrics. */
const driver: AgentDriver = {
async run() {
return {
tokens: DRIVER_TOKENS,
turns: DRIVER_TURNS,
imputedCostUsd: DRIVER_COST,
transcript: [{ kind: "mcp", server: "gitea-mcp", tool: "edit_issue" }],
finalReport: "Closed the issue.",
stoppedByTurnCap: false,
};
},
};
/**
* A fake driver that completes cleanly (not turn-capped, clean MCP transcript
* that audits clean on the gitea-mcp arm). Paired with the unmutated host, the
* run finishes but the checker scores it incorrect.
*/
const cleanDriver: 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: "Done.",
stoppedByTurnCap: false,
};
},
};
/**
* A fake driver reporting it hit the turn cap (`stoppedByTurnCap: true`). The
* other fields are arbitrary-but-valid literals; the run should be recorded as a
* confused failure regardless of them.
*/
const turnCappedDriver: AgentDriver = {
async run() {
return {
tokens: { freshInput: 10, cacheCreation: 0, cacheRead: 0, output: 5 },
turns: 10,
imputedCostUsd: 0.02,
transcript: [],
finalReport: "",
stoppedByTurnCap: true,
};
},
};
/**
* A fake driver that never resolves on its own — it settles only when its abort
* signal fires. Paired with a clock whose backstop timer fires first, it models a
* run that hangs past the wall-clock bound: the runner aborts the signal, and the
* driver then settles so no forever-pending promise is leaked.
*/
const hangingDriver: AgentDriver = {
run: ({ signal }) =>
new Promise((resolve) => {
signal.addEventListener("abort", () =>
resolve({
tokens: { freshInput: 0, cacheCreation: 0, cacheRead: 0, output: 0 },
turns: 0,
imputedCostUsd: 0,
transcript: [],
finalReport: "",
stoppedByTurnCap: false,
}),
);
}),
};
/**
* A fake driver whose transcript reaches the shell on the shell-disabled
* gitea-mcp arm — a foreign-tool leak per the audit's contract (proven in
* bench/audit.test.ts). The post-run audit should flag this cell invalid.
*/
const leakingDriver: AgentDriver = {
async run() {
return {
tokens: { freshInput: 0, cacheCreation: 0, cacheRead: 0, output: 0 },
turns: 1,
imputedCostUsd: 0,
transcript: [{ kind: "shell", command: "curl https://git.example.test/api/v1/repos" }],
finalReport: "",
stoppedByTurnCap: false,
};
},
};
describe("runCell", () => {
let binRoot: string;
let storeRoot: string;
beforeEach(() => {
binRoot = mkdtempSync(join(tmpdir(), "bench-runner-bin-"));
storeRoot = mkdtempSync(join(tmpdir(), "bench-runner-"));
});
afterEach(() => {
rmSync(binRoot, { recursive: true, force: true });
rmSync(storeRoot, { recursive: true, force: true });
});
// Behavior: running one cell on the happy path provisions and seeds a fresh
// repository, runs the agent under its arm, captures the post-run snapshot and
// scores it (a PASS here, because the fake capture returns the task's own
// expected end state), appends exactly one sample carrying the driver's four
// token components / turns / imputed cost and a passing outcome, and deletes
// the repository afterward (benchmark-harness spec, runner tracer bullet). The
// gitea-mcp arm needs no host binaries and its MCP transcript audits clean.
it("provisions, seeds, runs, scores a pass, records one sample, and deletes the repo", async () => {
const { host, calls } = createFakeHost();
const store = createSampleStore(storeRoot);
const trial = 3;
const outcome = await runCell({
arm: "gitea-mcp",
task: SAMPLE_TASK,
trial,
access: ACCESS,
host,
driver,
store,
bounds: { turnCap: 10, wallClockMs: 60_000 },
build: { binRoot },
});
// The lifecycle ran end to end, deleting exactly the provisioned repo.
expect(calls.provisioned).toBe(true);
expect(calls.seeded).toBe(true);
expect(calls.captured).toBe(true);
expect(calls.deletedCoords).toEqual(COORDS);
// runCell resolves to a recorded outcome (not invalid).
expect(outcome.kind).toBe("recorded");
if (outcome.kind !== "recorded") return;
// Exactly one sample landed in this cell, carrying the driver's metrics
// unchanged, the task's coordinates, the trial passed in, and a passing outcome.
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;
expect(sample.arm).toBe("gitea-mcp");
expect(sample.taskId).toBe(SAMPLE_TASK.id);
expect(sample.tier).toBe(SAMPLE_TASK.tier);
expect(sample.trial).toBe(trial);
expect(sample.tokens).toEqual(DRIVER_TOKENS);
expect(sample.turns).toBe(DRIVER_TURNS);
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");
expect(sample.durationMs).toBeGreaterThanOrEqual(0);
// The returned record is the same sample that was stored.
expect(outcome.record).toEqual(sample);
});
// Behavior: the run is bounded by a turn cap, and a run that hit it records a
// failure tagged confused (benchmark-harness spec, "confused-versus-hung"). The
// driver reports the cap was hit via stoppedByTurnCap: true, so the recorded
// sample's outcome must be { pass: false, failure: "confused" } — the failed
// outcome shape and the "confused" tag are independent literals fixed by
// result.ts's Outcome/FailureTag contract, not recomputed from runner.ts. Still
// exactly one sample lands and runCell resolves to a recorded outcome.
it("records a confused failure when the run hits the turn cap", async () => {
const { host } = createFakeHost();
const store = createSampleStore(storeRoot);
const outcome = await runCell({
arm: "gitea-mcp",
task: SAMPLE_TASK,
trial: 1,
access: ACCESS,
host,
driver: turnCappedDriver,
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;
expect(sample.outcome).toEqual({ pass: false, failure: "confused" });
});
// Behavior: the run is also bounded by a wall-clock backstop, and a run that
// exceeds it records a failure tagged hung (benchmark-harness spec,
// "confused-versus-hung"). An injected clock fires the backstop timer before the
// (never-self-resolving) driver finishes; the runner aborts the signal, which
// lets the driver settle. The recorded sample's outcome must be
// { pass: false, failure: "hung" } — a hung outcome is an independent literal
// fixed by result.ts's Outcome/FailureTag contract, not recomputed from
// runner.ts. Still exactly one sample lands and runCell resolves to recorded.
it("records a hung failure when the run exceeds the wall-clock backstop", async () => {
const { host } = createFakeHost();
const store = createSampleStore(storeRoot);
// Fire the backstop timer promptly (0ms) with a real clearable handle, so the
// wall-clock bound trips before the hanging driver would ever resolve.
const clock = {
setTimer: (_ms: number, fn: () => void) => {
const h = setTimeout(fn, 0);
return { clear: () => clearTimeout(h) };
},
};
const outcome = await runCell({
arm: "gitea-mcp",
task: SAMPLE_TASK,
trial: 1,
access: ACCESS,
host,
driver: hangingDriver,
store,
bounds: { turnCap: 10, wallClockMs: 50 },
build: { binRoot },
clock,
});
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;
expect(sample.outcome).toEqual({ pass: false, failure: "hung" });
});
// Behavior: a transcript audit runs after each cell, and a run in which a
// foreign tool was reached is flagged invalid instead of scored (benchmark-
// harness spec, "Tool isolation"). Here the gitea-mcp arm (shell disabled) has a
// shell command in its transcript — a leak by the audit's contract (proven in
// bench/audit.test.ts). So runCell must yield { kind: "invalid", leaks } with a
// non-empty leaks array, append NO sample (the store stays empty), and still
// delete the provisioned repo. These come from the CellOutcome contract and the
// spec's "invalid instead of scored", not from runner.ts's internals.
it("flags the cell invalid without scoring when the transcript reaches a foreign tool, still deleting the repo", async () => {
const { host, calls } = createFakeHost();
const store = createSampleStore(storeRoot);
const outcome = await runCell({
arm: "gitea-mcp",
task: SAMPLE_TASK,
trial: 1,
access: ACCESS,
host,
driver: leakingDriver,
store,
bounds: { turnCap: 10, wallClockMs: 60_000 },
build: { binRoot },
});
// Invalid instead of scored: a non-empty leaks array, and no sample appended.
expect(outcome.kind).toBe("invalid");
if (outcome.kind !== "invalid") return;
expect(outcome.leaks.length).toBeGreaterThan(0);
expect(store.read({ arm: "gitea-mcp", taskId: SAMPLE_TASK.id })).toHaveLength(0);
// Cleanup still happens: the provisioned repo is deleted.
expect(calls.deletedCoords).toEqual(COORDS);
});
// Behavior: the recorded sample carries the checker's pass/fail outcome, and a
// run that finished cleanly (not turn-capped, no leak) but whose post-run
// snapshot does not satisfy the task's scoring spec is scored a failure tagged
// incorrect (benchmark-harness spec; result.ts's FailureTag). The unmutated host
// returns the seed state where the target issue is still OPEN, but the task's
// spec expects it CLOSED, so the checker's full-state diff fails. The expected
// outcome { pass: false, failure: "incorrect" } is an independent literal from
// result.ts's contract, not recomputed from runner.ts.
it("records an incorrect failure when a clean run's snapshot does not satisfy the scoring spec", async () => {
const { host } = createUnmutatedHost();
const store = createSampleStore(storeRoot);
const outcome = await runCell({
arm: "gitea-mcp",
task: SAMPLE_TASK,
trial: 1,
access: ACCESS,
host,
driver: cleanDriver,
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;
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");
});
});

271
bench/runner.ts Normal file
View File

@@ -0,0 +1,271 @@
// The single-cell runner: the tracer bullet that threads every layer to run one
// `(arm, task, trial)` cell end to end and record an immutable result. It
// provisions and seeds a fresh throwaway repository, runs the agent under exactly
// the active arm's tool with the guard active, bounds the run by a turn cap and a
// wall-clock backstop, captures and scores the post-run state, appends the result
// sample to the store, and deletes the repository — auditing the transcript so a
// run that reached a foreign tool is flagged invalid rather than scored.
//
// The two boundaries the runner cannot make deterministic — the live host and the
// Claude Agent SDK — are factored behind the `BenchHost` and `AgentDriver` seams,
// so the orchestration here is unit-tested with fakes while the live wiring is
// validated by a smoke run (runner.smoke.test.ts), mirroring the seed tier.
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, 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";
/**
* What the agent driver reports from one run: the four token components (folding
* in the auxiliary small model, per the cost-equivalent-token metric), the turn
* count, the imputed cost, the transcript for the post-run audit, the agent's
* final report for read tasks, and whether the run stopped because it hit the
* turn cap (which the runner tags as a confused failure).
*/
export interface AgentRun {
tokens: TokenComponents;
turns: number;
imputedCostUsd: number;
transcript: ToolUse[];
finalReport: string;
stoppedByTurnCap: boolean;
}
/** The inputs the runner hands the driver for one run. */
export interface AgentRunInput {
/** The assembled arm (system prompt plus tool/guard or MCP configuration). */
arm: ArmDefinition;
/** The task's natural-language intent. */
intent: string;
/** The turn cap the driver must enforce, reporting `stoppedByTurnCap`. */
turnCap: number;
/** Aborted when the wall-clock backstop fires; the driver must resolve on abort. */
signal: AbortSignal;
}
/**
* The agent driver seam. The production implementation drives the Claude Agent
* SDK on the maintainer's subscription (sdk-driver.ts); tests inject a fake.
*/
export interface AgentDriver {
run(input: AgentRunInput): Promise<AgentRun>;
}
/**
* The live-host surface the runner drives, factored out so the orchestration is
* testable with a fake. The production implementation talks to the real Gitea
* host (seed.ts and snapshot.ts); its value is the real API interaction, so it is
* validated by the smoke run rather than mocked.
*/
export interface BenchHost {
/** Create and return a fresh, empty throwaway repository. */
provision(): Promise<RepoCoords>;
/** Seed the repository to the deterministic ground truth. */
seed(coords: RepoCoords): Promise<RepoState>;
/** Read the full post-run repository state as a snapshot. */
capture(coords: RepoCoords): Promise<RepoState>;
/** Best-effort deletion of the throwaway repository. */
delete(coords: RepoCoords): Promise<void>;
}
/** The two bounds every run is held within. */
export interface RunBounds {
/** Maximum agent turns; exceeding it is a confused failure. */
turnCap: number;
/** Wall-clock backstop in milliseconds; exceeding it is a hung failure. */
wallClockMs: number;
}
/**
* The clock and timer the runner uses, injectable so timing is deterministic in
* tests. Defaults to real wall-clock time and `setTimeout`.
*/
export interface RunnerClock {
now: () => number;
setTimer: (ms: number, fn: () => void) => { clear: () => void };
}
/** Everything needed to run one `(arm, task, trial)` cell. */
export interface RunCellInput {
arm: Arm;
task: BenchTask;
trial: number;
access: BenchAccess;
host: BenchHost;
driver: AgentDriver;
store: SampleStore;
bounds: RunBounds;
build: BuildArmOptions;
clock?: Partial<RunnerClock>;
}
/**
* The result of running one cell: either a scored sample was recorded, or the run
* was flagged invalid — a foreign tool was reached — and left unscored, so it
* never becomes a sample in the store.
*/
export type CellOutcome =
| { kind: "recorded"; record: ResultRecord }
| { kind: "invalid"; leaks: string[] };
const DEFAULT_CLOCK: RunnerClock = {
now: () => Date.now(),
setTimer: (ms, fn) => {
const handle = setTimeout(fn, ms);
return { clear: () => clearTimeout(handle) };
},
};
/** Token components for a run that produced no measurable consumption (a hung run). */
const NO_TOKENS: TokenComponents = { freshInput: 0, cacheCreation: 0, cacheRead: 0, output: 0 };
/**
* Run one cell end to end. Provisions and seeds a throwaway repository, runs the
* agent under the arm with a turn cap and a wall-clock backstop, audits the
* transcript, scores the completed run, appends the sample, and always deletes
* the repository. Exceeding the turn cap records a confused failure; exceeding the
* wall-clock backstop records a hung failure; a transcript that reached a foreign
* tool is flagged invalid rather than scored.
*/
export async function runCell(input: RunCellInput): Promise<CellOutcome> {
const { arm, task, trial, access, host, driver, store, bounds, build } = input;
const clock: RunnerClock = { ...DEFAULT_CLOCK, ...input.clock };
const coords = await host.provision();
try {
await host.seed(coords);
const context: SharedContext = { coords, access };
const armDef = buildArm(arm, context, build);
const started = clock.now();
const result = await runBounded(driver, armDef, task.intent, bounds, clock);
const durationMs = clock.now() - started;
// 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" }, undefined, undefined, clock),
);
}
const run = result.run;
// The post-run audit is authoritative on validity: a reached foreign tool
// invalidates the trial rather than letting it be scored or recorded.
const audit = auditTranscript(armDef, run.transcript);
if (!audit.clean) {
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, 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, report, run.transcript, clock),
);
} finally {
await host.delete(coords);
}
}
/** The bounded outcome of driving the agent: it either ran, or the backstop fired. */
type BoundedResult = { kind: "ran"; run: AgentRun } | { kind: "hung" };
/**
* Drive the agent under the wall-clock backstop. The driver enforces the turn cap
* itself (reporting `stoppedByTurnCap`); this races it against a timer so a driver
* that genuinely hangs cannot block the cell forever. When the timer wins, the
* signal is aborted so a cooperating driver can stop, and the run is hung.
*/
async function runBounded(
driver: AgentDriver,
arm: ArmDefinition,
intent: string,
bounds: RunBounds,
clock: RunnerClock,
): Promise<BoundedResult> {
const controller = new AbortController();
let timer: { clear: () => void } | undefined;
const backstop = new Promise<BoundedResult>((resolve) => {
timer = clock.setTimer(bounds.wallClockMs, () => resolve({ kind: "hung" }));
});
try {
return await Promise.race([
driver
.run({ arm, intent, turnCap: bounds.turnCap, signal: controller.signal })
.then((run) => ({ kind: "ran" as const, run })),
backstop,
]);
} finally {
timer?.clear();
controller.abort();
}
}
/**
* Score a completed (not turn-capped) run: capture the post-run snapshot and diff
* it against the task's expected end state for a mutation, or match the agent's
* 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, spec: ScoringSpec, run: AgentRun): Promise<Outcome> {
const snapshot = await host.capture(coords);
const check =
spec.kind === "mutation"
? score(spec, { kind: "mutation", state: snapshot })
: score(spec, { kind: "read", report: run.finalReport });
return check.pass ? { pass: true } : { pass: false, failure: "incorrect" };
}
/** Assemble the immutable result record for one run. */
function makeRecord(
input: RunCellInput,
tokens: TokenComponents,
turns: number,
imputedCostUsd: number,
durationMs: number,
outcome: Outcome,
report: string | undefined,
transcript: TranscriptEntry[] | undefined,
clock: RunnerClock,
): ResultRecord {
return {
arm: input.arm,
taskId: input.task.id,
tier: input.task.tier,
trial: input.trial,
timestamp: new Date(clock.now()).toISOString(),
tokens,
turns,
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 } : {}),
};
}
/** Append the record and return it as the recorded cell outcome. */
function recorded(store: SampleStore, record: ResultRecord): CellOutcome {
store.append(record);
return { kind: "recorded", record };
}

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { score } from "./checker.js";
import type { ScoringSpec } from "./scoring-spec.js";
describe("ScoringSpec contract", () => {
it("expresses both a mutation's expected end state and a read's required answer facts", () => {
// A mutation spec fixes a rich expected end state: a labelled, commented,
// closed issue AND a merged pull request carrying a review. This proves the
// contract can express the whole scored surface, not just a single field.
const mutationSpec: ScoringSpec = {
kind: "mutation",
expected: {
labels: [{ name: "bug", color: "#d73a4a", description: "Something is broken" }],
issues: [
{
number: 1,
title: "Login button misaligned",
body: "Overflows on mobile.",
state: "closed",
labels: ["bug"],
assignees: ["octocat"],
comments: [{ author: "maintainer", body: "Fixed in the latest build." }],
},
],
pullRequests: [
{
number: 2,
title: "Fix login button alignment",
body: "Closes #1.",
state: "merged",
labels: ["bug"],
assignees: ["octocat"],
comments: [{ author: "octocat", body: "Ready for review." }],
reviews: [
{
author: "maintainer",
kind: "approved",
body: "Looks good.",
comments: [{ author: "maintainer", body: "Nice fix." }],
},
],
},
],
},
};
expect(mutationSpec.kind).toBe("mutation");
// Scoring the very state the spec fixes must pass — the contract round-trips
// through the scorer.
expect(score(mutationSpec, { kind: "mutation", state: mutationSpec.expected })).toEqual({
pass: true,
});
// A read spec instead carries required answer facts. This proves the contract
// can express the read-task family.
const readSpec: ScoringSpec = {
kind: "read",
facts: [
{ description: "count of open issues", anyOf: ["3 open issues", "three open issues"] },
{ description: "the stale issue's number", anyOf: ["#42", "issue 42"] },
],
};
expect(readSpec.kind).toBe("read");
// A report rendering both facts must pass.
const report = "There are 3 open issues; the oldest untouched one is #42.";
expect(score(readSpec, { kind: "read", report })).toEqual({ pass: true });
});
});

158
bench/scoring-spec.ts Normal file
View File

@@ -0,0 +1,158 @@
// The scoring-spec contract: a task's expected outcome, in the form the checker
// consumes and the runner and task suite produce. Two kinds mirror the two ways
// the benchmark scores a run — a mutation task fixes the repository's expected
// end state (diffed in full so collateral damage is caught), and a read task
// fixes the facts the agent's final report must contain (matched deterministically,
// with no LLM judge).
//
// These are pure contract types with no logic; the checker (checker.ts) is the
// seam that scores an actual run against a spec of either kind. The benchmark's
// own vocabulary (arm, cell, checker, seed) is documented in bench/README.md and
// the benchmark-harness spec, deliberately kept out of the tool's own domain
// glossary.
/**
* A repository label definition. The seed fixes each label's colour, so colour
* and description are part of the expected end state; applied label *names* on an
* issue or pull request are compared separately, as an order-independent set.
*/
export interface Label {
name: string;
color: string;
description?: string;
}
/**
* One comment on an issue, pull request, or review. Comments are matched by
* author and body; the host-assigned id and timestamps are volatile and are
* dropped before comparison.
*/
export interface Comment {
author: string;
body: string;
/** Volatile: host-assigned, dropped by normalization. */
id?: number;
/** Volatile: dropped by normalization. */
createdAt?: string;
}
/** An issue's open/closed state. */
export type IssueState = "open" | "closed";
/** A pull request's state; unlike an issue, it may also be merged. */
export type PullRequestState = "open" | "closed" | "merged";
/** The kind of review a single user may leave on a pull request. */
export type ReviewKind = "comment" | "approved" | "request-changes";
/**
* One review on a pull request. The single-user seed allows comment-type reviews
* (and, where the host permits self-review, approvals and change requests).
* Reviews are matched by author, kind, body, and their inline comments; the
* host-assigned id and timestamp are volatile.
*/
export interface Review {
author: string;
kind: ReviewKind;
body: string;
/** Inline review comments; matched by author and body. */
comments: Comment[];
/** Volatile: host-assigned, dropped by normalization. */
id?: number;
/** Volatile: dropped by normalization. */
createdAt?: string;
}
/**
* One issue in the expected (or actual) repository state. The issue number is
* deterministic ground truth from the seed and keys the diff; the host-assigned
* id and timestamps are volatile and dropped.
*/
export interface Issue {
number: number;
title: string;
body: string;
state: IssueState;
/** Applied label names; compared as an order-independent set. */
labels: string[];
/** Assignee usernames; compared as an order-independent set. */
assignees: string[];
/** Comments; matched by author and body. */
comments: Comment[];
/** Volatile: host-assigned, dropped by normalization. */
id?: number;
/** Volatile: dropped by normalization. */
createdAt?: string;
/** Volatile: dropped by normalization. */
updatedAt?: string;
}
/**
* One pull request in the expected (or actual) repository state. Shares the
* conversation surface with an issue (labels, assignees, comments) and adds the
* merged state and reviews.
*/
export interface PullRequest {
number: number;
title: string;
body: string;
state: PullRequestState;
/** Applied label names; compared as an order-independent set. */
labels: string[];
/** Assignee usernames; compared as an order-independent set. */
assignees: string[];
/** Comments; matched by author and body. */
comments: Comment[];
/** Reviews; matched by author, kind, body, and inline comments. */
reviews: Review[];
/** Volatile: host-assigned, dropped by normalization. */
id?: number;
/** Volatile: dropped by normalization. */
createdAt?: string;
/** Volatile: dropped by normalization. */
updatedAt?: string;
}
/**
* A full snapshot of the throwaway repository's scored surface. A mutation task's
* expected end state is one of these; the checker captures the actual post-run
* state in the same shape and diffs the two in full, so both the intended change
* and any collateral damage are caught.
*/
export interface RepoState {
labels: Label[];
issues: Issue[];
pullRequests: PullRequest[];
}
/**
* One fact a read task's answer must contain. The fact is satisfied when the
* agent's final report contains any one of `anyOf`'s renderings (after
* 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;
}
/**
* A task's scoring spec: either a mutation's expected end state or a read's
* required answer facts. The runner and task suite produce one of these per
* task; the checker consumes it to turn a completed run into a pass/fail.
*/
export type ScoringSpec =
| { kind: "mutation"; expected: RepoState }
| { kind: "read"; facts: RequiredFact[] };

119
bench/sdk-driver.test.ts Normal file
View File

@@ -0,0 +1,119 @@
import { readdirSync, rmSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createAgentWorkdir, sumTokens } from "./sdk-driver.js";
import type { SdkResultMessage } from "./sdk-driver.js";
describe("sumTokens", () => {
// Behavior: sumTokens reads per-model token usage from the SDK result's
// `modelUsage` map and sums the four token components across EVERY model,
// folding in the auxiliary small model the runtime invokes for internal
// chores — because that is real consumption against the same allowance
// (see the token-components note in result.ts). Crucially it reads the SDK's
// camelCase field names (inputTokens / outputTokens / cacheCreationInputTokens
// / cacheReadInputTokens); this is a regression guard against a bug where the
// driver read snake_case keys, so every component silently summed to zero.
//
// The result carries two models — a main model and the aux small model — with
// DISTINCT numbers on every field, so a wrong field mapping cannot be masked
// by another and both models must be folded in to reach the totals. The
// expected sums are derived BY HAND from the two models, independent of how
// sumTokens computes them, per the metric mapping
// (inputTokens -> freshInput, outputTokens -> output,
// cacheCreationInputTokens -> cacheCreation, cacheReadInputTokens -> cacheRead):
// freshInput = 500 + 30 = 530
// output = 200 + 8 = 208
// cacheCreation = 3000 + 100 = 3100
// cacheRead = 10000 + 400 = 10400
it("sums the four camelCase token components across every model, folding in the auxiliary model", () => {
const result: SdkResultMessage = {
type: "result",
subtype: "success",
modelUsage: {
"claude-opus-4-8": {
inputTokens: 500,
outputTokens: 200,
cacheCreationInputTokens: 3000,
cacheReadInputTokens: 10000,
},
"claude-haiku-aux": {
inputTokens: 30,
outputTokens: 8,
cacheCreationInputTokens: 100,
cacheReadInputTokens: 400,
},
},
};
expect(sumTokens(result)).toEqual({
freshInput: 530,
output: 208,
cacheCreation: 3100,
cacheRead: 10400,
});
});
// Behavior: when the result carries NO per-model `modelUsage` breakdown,
// sumTokens falls back to the aggregate `usage` block. Unlike the per-model
// map, the SDK reports this aggregate in snake_case (input_tokens /
// output_tokens / cache_creation_input_tokens / cache_read_input_tokens), so
// this pins that the fallback path reads the OTHER casing correctly and that
// the two shapes are not confused. This is a regression guard against reading
// the wrong casing on the fallback path.
//
// There is a single aggregate source, so each expected component equals its
// own field's value; the four numbers are DISTINCT so a wrong field mapping
// cannot be masked by another. Values are hand-worked from the aggregate,
// independent of how sumTokens computes them, per the mapping
// (input_tokens -> freshInput, output_tokens -> output,
// cache_creation_input_tokens -> cacheCreation,
// cache_read_input_tokens -> cacheRead):
// freshInput = 700, output = 90, cacheCreation = 4000, cacheRead = 20000.
it("falls back to the snake_case aggregate usage when no per-model modelUsage is present", () => {
const result: SdkResultMessage = {
type: "result",
subtype: "success",
usage: {
input_tokens: 700,
output_tokens: 90,
cache_creation_input_tokens: 4000,
cache_read_input_tokens: 20000,
},
};
expect(sumTokens(result)).toEqual({
freshInput: 700,
cacheCreation: 4000,
cacheRead: 20000,
output: 90,
});
});
});
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 });
}
});
});

284
bench/sdk-driver.ts Normal file
View File

@@ -0,0 +1,284 @@
// The production agent driver: the adapter that runs one arm through the Claude
// Agent SDK on the maintainer's subscription and reports the metrics and
// transcript the runner records. It is the concrete `AgentDriver` behind the seam
// the runner depends on; the deterministic runner tests inject a fake instead, and
// this live wiring is exercised only by the smoke run.
//
// The Agent SDK is loaded through a computed dynamic import so the harness's
// deterministic tier and the project's typecheck never require the package to be
// installed — the SDK is needed only for live runs, exactly as the seed smoke tier
// needs a live Gitea host. A local interface describes the slice of the SDK this
// adapter consumes, so this side of the boundary stays type-checked even though the
// package is optional.
//
// Isolation is enforced in-band via the SDK's permission callback: every Bash
// command on a shell arm is put through the arm's own guard, and the shell is
// disabled entirely on the MCP arm. Only tools that were permitted to run are
// 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";
import type { AgentDriver, AgentRun, AgentRunInput } from "./runner.js";
/** The default fixed model every arm is run on (overridable for the whole run). */
const DEFAULT_MODEL = "claude-opus-4-8";
/** The Agent SDK package, resolved at run time so it is an optional peer of the harness. */
const SDK_MODULE = "@anthropic-ai/claude-agent-sdk";
/** Configuration for the SDK-backed driver. */
export interface SdkDriverConfig {
/** The single fixed model all arms run on. Defaults to the latest Opus. */
model?: string;
/** Override the SDK module specifier (tests/tooling); defaults to the real package. */
moduleSpecifier?: string;
}
// --- The slice of the Claude Agent SDK this adapter consumes ------------------
/**
* Per-model token usage as the SDK's `modelUsage` reports it, one entry per model
* the run touched. The SDK reports these in camelCase — distinct from the aggregate
* {@link SdkUsage} below, which it reports in the Anthropic API's snake_case shape.
* Reading the wrong casing silently yields zeros, so the two are kept separate.
*/
interface SdkModelUsage {
inputTokens?: number;
outputTokens?: number;
cacheCreationInputTokens?: number;
cacheReadInputTokens?: number;
}
/** The aggregate per-request usage (snake_case), the fallback when no per-model breakdown is present. */
interface SdkUsage {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
export interface SdkResultMessage {
type: "result";
/** `error_max_turns` when the run hit the turn cap. */
subtype: string;
usage?: SdkUsage;
/** Per-model usage, including the auxiliary small model the runtime invokes. */
modelUsage?: Record<string, SdkModelUsage>;
total_cost_usd?: number;
num_turns?: number;
result?: string;
}
interface SdkContentBlock {
type: string;
name?: string;
input?: Record<string, unknown>;
}
interface SdkAssistantMessage {
type: "assistant";
message: { content: SdkContentBlock[] };
}
type SdkMessage = SdkResultMessage | SdkAssistantMessage | { type: string };
type SdkPermissionResult =
| { behavior: "allow"; updatedInput: Record<string, unknown> }
| { behavior: "deny"; message: string };
interface SdkStdioServer {
type: "stdio";
command: string;
args: string[];
env: Record<string, string>;
}
interface SdkQueryOptions {
model: string;
/** Fixed at zero across every arm so runs are as deterministic as the model allows. */
temperature: number;
systemPrompt: string;
maxTurns: number;
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[];
}
interface SdkModule {
query: (args: { prompt: string; options: SdkQueryOptions }) => AsyncIterable<SdkMessage>;
}
// --- Metric and transcript extraction ----------------------------------------
/**
* Sum the four token components across every model the run touched, so the
* auxiliary small model the runtime invokes is folded in as the metric spec
* requires. The per-model `modelUsage` (camelCase) is the primary source; the
* aggregate `usage` (snake_case) is the fallback when no per-model breakdown is
* present. The two shapes use different field casing, so each is read with its
* own names — reading the wrong casing is what silently produced zero tokens.
*/
export function sumTokens(result: SdkResultMessage): TokenComponents {
const total: TokenComponents = { freshInput: 0, cacheCreation: 0, cacheRead: 0, output: 0 };
const perModel = result.modelUsage ? Object.values(result.modelUsage) : [];
if (perModel.length > 0) {
for (const usage of perModel) {
total.freshInput += usage.inputTokens ?? 0;
total.cacheCreation += usage.cacheCreationInputTokens ?? 0;
total.cacheRead += usage.cacheReadInputTokens ?? 0;
total.output += usage.outputTokens ?? 0;
}
return total;
}
if (result.usage) {
total.freshInput += result.usage.input_tokens ?? 0;
total.cacheCreation += result.usage.cache_creation_input_tokens ?? 0;
total.cacheRead += result.usage.cache_read_input_tokens ?? 0;
total.output += result.usage.output_tokens ?? 0;
}
return total;
}
/** Classify one tool invocation into the isolation-relevant shape the audit consumes. */
function classifyTool(toolName: string, input: Record<string, unknown>): ToolUse {
if (toolName === "Bash") {
return { kind: "shell", command: String(input.command ?? "") };
}
if (toolName.startsWith("mcp__")) {
const [, server = "", tool = ""] = toolName.split("__");
return { kind: "mcp", server, tool };
}
return { kind: "other", name: toolName };
}
/**
* Build the SDK-backed agent driver. Each run drives one arm through the Agent
* SDK: the arm's assembled system prompt, the task intent as the user prompt, the
* fixed model, the turn cap as `maxTurns`, and the arm's tool configuration —
* either its curated shell PATH with the guard on the permission callback, or the
* MCP server attached with the shell disabled. The run is wired to the runner's
* abort signal so the wall-clock backstop can stop it.
*/
export function sdkAgentDriver(config: SdkDriverConfig = {}): AgentDriver {
const model = config.model ?? DEFAULT_MODEL;
const specifier = config.moduleSpecifier ?? SDK_MODULE;
return {
async run(input: AgentRunInput): Promise<AgentRun> {
const { query } = (await import(specifier)) as SdkModule;
const controller = new AbortController();
if (input.signal.aborted) {
controller.abort();
} else {
input.signal.addEventListener("abort", () => controller.abort(), { once: true });
}
// The transcript records only tools that were permitted to run, so the
// runner's audit sees what actually executed, not blocked attempts.
const transcript: ToolUse[] = [];
const canUseTool = async (
toolName: string,
toolInput: Record<string, unknown>,
): Promise<SdkPermissionResult> => {
const use = classifyTool(toolName, toolInput);
const denial = foreignToolReason(input.arm, use);
if (denial !== null) {
return { behavior: "deny", message: denial };
}
transcript.push(use);
return { behavior: "allow", updatedInput: toolInput };
};
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;
}
}
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",
};
} 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,
model: string,
turnCap: number,
controller: AbortController,
canUseTool: SdkQueryOptions["canUseTool"],
cwd: string,
): SdkQueryOptions {
const options: SdkQueryOptions = {
model,
// Temperature zero across all arms, per the runner-and-metrics spec, so the
// comparison measures the tool rather than sampling noise.
temperature: 0,
systemPrompt: arm.systemPrompt,
maxTurns: turnCap,
abortController: controller,
canUseTool,
// 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.
// 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 } };
options.disallowedTools = ["Bash"];
}
return options;
}

162
bench/seed-plan.test.ts Normal file
View File

@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import { SEED_PLAN, groundTruth } from "./seed-plan.js";
describe("SEED_PLAN discriminating dimensions", () => {
it("spreads its issues across both states and both assignee-presence values", () => {
// The single-user-seed ADR and the benchmark-harness spec name state and
// assignee presence (assigned-to-self versus unassigned) as discriminating
// dimensions. So the seeded issues must exercise both poles of each axis:
// at least one open AND one closed, at least one assigned-to-self AND one
// unassigned. These are independent spec-derived invariants, not values
// recomputed from the seed.
const states = new Set(SEED_PLAN.issues.map((issue) => issue.state));
const assigneePresence = new Set(
SEED_PLAN.issues.map((issue) => issue.assignToSelf),
);
expect(states).toContain("open");
expect(states).toContain("closed");
expect(assigneePresence).toContain(true);
expect(assigneePresence).toContain(false);
});
it("repeats a title keyword across several issues so a keyword filter selects a subset", () => {
// Title keyword is one of the four discriminating dimensions, and the spec
// weights the suite toward find-then-act tasks where a filter must select
// more than one issue. So a bug-cluster keyword like "crash" must recur:
// at least two distinct issues carry it (case-insensitively). "crash" is an
// independent domain literal, not a value read back from the seed.
const keyword = "crash";
const matching = SEED_PLAN.issues.filter((issue) =>
issue.title.toLowerCase().includes(keyword),
);
expect(matching.length).toBeGreaterThanOrEqual(2);
});
it("defines a closed set of fixed-colour labels that every applied issue label belongs to", () => {
// The spec says the seed establishes a fixed set of labels with fixed
// colours and that issues vary by label; the ADR keeps the seed small and
// deterministic. So there must be at least three distinct labels, each with
// a fixed six-digit hex colour, and no issue may apply a label the plan does
// not define — the label set is closed. The hex pattern and the closed-set
// relation are independent spec-derived invariants, not seed values.
const hexColour = /^#[0-9a-fA-F]{6}$/;
const definedNames = new Set(SEED_PLAN.labels.map((label) => label.name));
expect(definedNames.size).toBeGreaterThanOrEqual(3);
for (const label of SEED_PLAN.labels) {
expect(label.color).toMatch(hexColour);
}
const appliedNames = new Set(
SEED_PLAN.issues.flatMap((issue) => issue.labels),
);
for (const name of appliedNames) {
expect(definedNames).toContain(name);
}
});
it("varies label application and gives at least one issue pre-existing comments", () => {
// The spec describes the seed as a spread of issues varying by label and
// pre-existing comments. So label application must span both extremes: at
// least one unlabelled issue and at least one carrying multiple labels; and
// at least one issue must arrive with pre-existing comments. These are
// independent spec-derived invariants, not values recomputed from the seed.
const unlabelled = SEED_PLAN.issues.filter(
(issue) => issue.labels.length === 0,
);
const multiLabelled = SEED_PLAN.issues.filter(
(issue) => issue.labels.length >= 2,
);
const commented = SEED_PLAN.issues.filter(
(issue) => issue.comments.length > 0,
);
expect(unlabelled.length).toBeGreaterThanOrEqual(1);
expect(multiLabelled.length).toBeGreaterThanOrEqual(1);
expect(commented.length).toBeGreaterThanOrEqual(1);
});
it("backs every pull request with a real feature branch and includes a labelled one and a reviewed one", () => {
// The spec says the seed provides a handful of pull requests including one
// labelled, one carrying an existing review, and one backed by a real pushed
// feature branch — and every Gitea pull request needs a real head branch
// with a diff to exist at all. So the set must be non-empty, every pull
// request must carry a non-empty head branch and a file (path and content),
// and at least one must be labelled and at least one must carry a review.
// These are independent spec-derived invariants, not seed values.
expect(SEED_PLAN.pullRequests.length).toBeGreaterThanOrEqual(1);
for (const pr of SEED_PLAN.pullRequests) {
expect(pr.headBranch.length).toBeGreaterThan(0);
expect(pr.filePath.length).toBeGreaterThan(0);
expect(pr.fileContent.length).toBeGreaterThan(0);
}
const labelled = SEED_PLAN.pullRequests.filter((pr) => pr.labels.length > 0);
const reviewed = SEED_PLAN.pullRequests.filter(
(pr) => pr.reviews.length > 0,
);
expect(labelled.length).toBeGreaterThanOrEqual(1);
expect(reviewed.length).toBeGreaterThanOrEqual(1);
});
it("numbers issues then pull requests in one shared sequence starting at 1", () => {
// A freshly provisioned Gitea repository draws issue and pull-request numbers
// from ONE shared sequence in creation order, and the seed creates all issues
// first, then all pull requests. So for N issues and M pull requests the
// issues carry 1..N in plan order and the pull requests carry N+1..N+M in
// plan order, with no overlap. The expected numbers are derived independently
// from the plan lengths (the shared-sequence rule), not read from groundTruth.
const n = SEED_PLAN.issues.length;
const m = SEED_PLAN.pullRequests.length;
const expectedIssueNumbers = Array.from({ length: n }, (_, i) => i + 1);
const expectedPrNumbers = Array.from({ length: m }, (_, i) => n + i + 1);
const state = groundTruth("maintainer");
expect(state.issues.map((issue) => issue.number)).toEqual(
expectedIssueNumbers,
);
expect(state.pullRequests.map((pr) => pr.number)).toEqual(expectedPrNumbers);
const allNumbers = [
...state.issues.map((issue) => issue.number),
...state.pullRequests.map((pr) => pr.number),
];
expect(new Set(allNumbers).size).toBe(allNumbers.length);
});
it("realizes the single-user seed: self-assignment reflects the plan and all authorship is the one user", () => {
// The single-user-seed ADR says all seed content is authored by the one
// account, and assignee presence is assigned-to-self versus unassigned. So
// in the realized state each issue's assignees is [user] exactly when the
// plan issue's assignToSelf is true and [] otherwise, and every comment and
// review carries author === user. Expected assignees are derived from
// SEED_PLAN.issues[i].assignToSelf (the ADR rule), not read from groundTruth.
const user = "seed-user";
const state = groundTruth(user);
state.issues.forEach((issue, i) => {
const expected = SEED_PLAN.issues[i]!.assignToSelf ? [user] : [];
expect(issue.assignees).toEqual(expected);
});
const commentAuthors = [
...state.issues.flatMap((issue) => issue.comments),
...state.pullRequests.flatMap((pr) => pr.comments),
...state.pullRequests.flatMap((pr) =>
pr.reviews.flatMap((review) => review.comments),
),
].map((comment) => comment.author);
const reviewAuthors = state.pullRequests
.flatMap((pr) => pr.reviews)
.map((review) => review.author);
for (const author of [...commentAuthors, ...reviewAuthors]) {
expect(author).toBe(user);
}
});
});

211
bench/seed-plan.ts Normal file
View File

@@ -0,0 +1,211 @@
// The seed plan: the deterministic ground truth every throwaway repository is
// brought to before a trial runs. It is pure declarative data — a fixed set of
// labels, a spread of issues, and a handful of pull requests — parametrized only
// by the single available user, whose identity fills the assignee and author
// dimensions the single-user seed collapses onto (see the single-user-seed ADR).
//
// Because only one Gitea account is available, the discriminating dimensions are
// label, state, assignee presence (assigned-to-self versus unassigned), and title
// keyword — not author. `groundTruth` realizes the plan into the RepoState the
// checker scores against, assigning the deterministic issue and pull-request
// numbers a fresh repository hands out in creation order.
//
// The live seeding that applies this plan lives in seed.ts and is validated by a
// smoke run, not by mocked unit tests (see the benchmark-harness spec's testing
// decisions).
import type { RepoState, ReviewKind } from "./scoring-spec.js";
/** A repository label the seed fixes, with its stable colour. */
export interface SeedLabel {
name: string;
color: string;
description?: string;
}
/**
* One issue in the plan. `assignToSelf` picks the assignee-presence dimension
* (the single user or nobody); `comments` are bodies the single user authors.
*/
export interface SeedIssue {
title: string;
body: string;
state: "open" | "closed";
labels: string[];
assignToSelf: boolean;
comments: string[];
}
/** One review the seed leaves on a pull request; the single user is the author. */
export interface SeedReview {
kind: ReviewKind;
body: string;
}
/**
* One pull request in the plan, always backed by a real feature branch carrying
* one file so the pull request has a genuine diff to propose.
*/
export interface SeedPullRequest {
title: string;
body: string;
headBranch: string;
filePath: string;
fileContent: string;
labels: string[];
comments: string[];
reviews: SeedReview[];
}
/** The whole deterministic seed: labels, issues, and pull requests. */
export interface SeedPlan {
labels: SeedLabel[];
issues: SeedIssue[];
pullRequests: SeedPullRequest[];
}
export const SEED_PLAN: SeedPlan = {
labels: [
{ name: "bug", color: "#d73a4a", description: "Something is broken" },
{ name: "enhancement", color: "#a2eeef", description: "A new feature or request" },
{ name: "documentation", color: "#0075ca", description: "Docs and readme changes" },
{ name: "priority", color: "#b60205", description: "Needs attention soon" },
],
issues: [
{
title: "Fix crash on startup",
body: "The app crashes immediately on a fresh launch.",
state: "open",
labels: ["bug"],
assignToSelf: true,
comments: ["I can reproduce this on Linux."],
},
{
title: "Add CSV export option",
body: "Users want to export their data as CSV.",
state: "open",
labels: ["enhancement"],
assignToSelf: false,
comments: [],
},
{
title: "Crash when saving large files",
body: "Saving a file over about 100 MB reliably crashes the editor.",
state: "open",
labels: ["bug", "priority"],
assignToSelf: true,
comments: [],
},
{
title: "Typo in installation docs",
body: "The install guide says 'yarn' where it should say 'npm'.",
state: "open",
labels: ["documentation"],
assignToSelf: false,
comments: ["Found another typo nearby."],
},
{
title: "Update README badges",
body: "The build badges in the README point at the old CI.",
state: "closed",
labels: ["documentation"],
assignToSelf: false,
comments: [],
},
{
title: "Crash in export dialog",
body: "Opening the export dialog twice crashes the app.",
state: "closed",
labels: ["bug"],
assignToSelf: true,
comments: [],
},
{
title: "Improve export performance",
body: "Exporting a large project is slow and blocks the UI.",
state: "open",
labels: ["enhancement"],
assignToSelf: false,
comments: [],
},
{
title: "Typo in error message",
body: "The save-failed dialog misspells 'occurred'.",
state: "closed",
labels: [],
assignToSelf: false,
comments: [],
},
],
pullRequests: [
{
title: "Implement CSV export",
body: "Adds the CSV export path requested in the issues.",
headBranch: "feature/csv-export",
filePath: "export.txt",
fileContent: "CSV export implementation notes.\n",
labels: ["enhancement"],
comments: [],
reviews: [],
},
{
title: "Fix startup crash",
body: "Guards the startup path that was throwing on a fresh launch.",
headBranch: "feature/fix-crash",
filePath: "fix.txt",
fileContent: "Startup crash fix notes.\n",
labels: [],
comments: [],
reviews: [
{
kind: "comment",
body: "Looks good overall, though the error handling could be tightened.",
},
],
},
{
title: "Refresh documentation",
body: "Updates the README and installation guide.",
headBranch: "feature/docs-refresh",
filePath: "docs.txt",
fileContent: "Documentation refresh notes.\n",
labels: [],
comments: ["Ready for review."],
reviews: [],
},
],
};
/**
* Realize the plan into the ground-truth RepoState for the given single user,
* assigning the deterministic numbers a freshly provisioned repository hands out:
* issues first in plan order, then pull requests, sharing one number space.
*/
export function groundTruth(user: string): RepoState {
const authored = (body: string) => ({ author: user, body });
const issues = SEED_PLAN.issues.map((issue, index) => ({
number: index + 1,
title: issue.title,
body: issue.body,
state: issue.state,
labels: [...issue.labels],
assignees: issue.assignToSelf ? [user] : [],
comments: issue.comments.map(authored),
}));
const pullRequests = SEED_PLAN.pullRequests.map((pr, index) => ({
number: SEED_PLAN.issues.length + index + 1,
title: pr.title,
body: pr.body,
state: "open" as const,
labels: [...pr.labels],
assignees: [],
comments: pr.comments.map(authored),
reviews: pr.reviews.map((review) => ({
author: user,
kind: review.kind,
body: review.body,
comments: [],
})),
}));
return { labels: SEED_PLAN.labels, issues, pullRequests };
}

97
bench/seed.smoke.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { CliDeps } from "../src/deps.js";
import { SEED_PLAN } from "./seed-plan.js";
import {
deleteRepo,
provisionRepo,
readSeedSummary,
resolveBenchAccess,
seedRepo,
type BenchAccess,
type RepoCoords,
type SeedSummary,
} from "./seed.js";
/**
* The seed smoke tier: a single live validation that provisioning and seeding a
* throwaway repository really brings it to the declared ground truth, and that
* re-seeding is idempotent. The benchmark-harness spec designates seed
* provisioning as validated by a smoke run against a real host rather than by
* mocks, since its value is the real API interaction. Like the e2e tier keys off
* GITEA_AXI_E2E_URL, this suite skips cleanly when GITEA_AXI_BENCH_LOGIN is
* unset, which counts as a pass. Expected values are derived from SEED_PLAN, the
* declared ground-truth contract, never from seed.ts.
*/
const login = process.env.GITEA_AXI_BENCH_LOGIN;
describe.skipIf(!login)("seed smoke: provisioning and seeding", () => {
let access: BenchAccess;
let coords: RepoCoords;
beforeAll(async () => {
const deps: CliDeps = {
env: process.env,
cwd: process.cwd(),
globals: { login },
};
access = await resolveBenchAccess(deps, login!);
coords = await provisionRepo(access);
}, 180_000);
afterAll(async () => {
if (access && coords) {
await deleteRepo(access, coords).catch(() => {});
}
}, 60_000);
it("provisioning a fresh repository and seeding it produces the fixed labels, the open/closed issue spread, and the labelled and reviewed pull requests", async () => {
await seedRepo(access, coords);
const summary = await readSeedSummary(access, coords);
for (const label of SEED_PLAN.labels) {
expect(summary.labelNames).toContain(label.name);
}
const expectedOpen = new Set(
SEED_PLAN.issues.filter((i) => i.state === "open").map((i) => i.title),
);
const expectedClosed = new Set(
SEED_PLAN.issues.filter((i) => i.state === "closed").map((i) => i.title),
);
expect(new Set(summary.openIssueTitles)).toEqual(expectedOpen);
expect(new Set(summary.closedIssueTitles)).toEqual(expectedClosed);
expect(summary.selfAssignedIssueCount).toBe(
SEED_PLAN.issues.filter((i) => i.assignToSelf).length,
);
expect(summary.issuesWithCommentsCount).toBe(
SEED_PLAN.issues.filter((i) => i.comments.length > 0).length,
);
expect(new Set(summary.pullTitles)).toEqual(
new Set(SEED_PLAN.pullRequests.map((pr) => pr.title)),
);
const expectedLabeledPull = SEED_PLAN.pullRequests.find(
(pr) => pr.labels.length > 0,
);
expect(expectedLabeledPull).toBeDefined();
expect(summary.labeledPullTitles).toContain(expectedLabeledPull!.title);
const expectedReviewedPull = SEED_PLAN.pullRequests.find(
(pr) => pr.reviews.length > 0,
);
expect(expectedReviewedPull).toBeDefined();
expect(summary.reviewedPullTitles).toContain(expectedReviewedPull!.title);
});
it("re-running the seed against an already-seeded repository is idempotent", async () => {
await seedRepo(access, coords);
const first: SeedSummary = await readSeedSummary(access, coords);
await seedRepo(access, coords);
const second: SeedSummary = await readSeedSummary(access, coords);
expect(second).toEqual(first);
});
});

566
bench/seed.ts Normal file
View File

@@ -0,0 +1,566 @@
// Seed provisioning: bring a freshly provisioned throwaway repository to the
// deterministic ground truth of SEED_PLAN, scripted entirely over the Gitea API
// against the live host. This is the imperative boundary the seed-plan realizes;
// its value is the real API interaction, so it is validated by a smoke run
// (seed.smoke.test.ts) rather than mocked unit tests — see the benchmark-harness
// spec's testing decisions.
//
// Authentication reuses gitea-axi's own credential discovery (the tea login store
// and its git-credential token helper) rather than introducing new secret
// handling: resolveBenchAccess is a thin adapter over src/tea.ts and the login
// selection in src/context.ts.
//
// Every step is idempotent, keyed by a natural identity — a label by name, an
// issue or pull request by title, a comment or review by body, a branch by name —
// so re-running the seed against an already-seeded repository reconciles to the
// same ground truth instead of duplicating it.
import type { CliDeps } from "../src/deps.js";
import { selectLogin } from "../src/context.js";
import { getToken, listLogins } from "../src/tea.js";
import { groundTruth, SEED_PLAN, type SeedIssue, type SeedPullRequest } from "./seed-plan.js";
import type { RepoState, ReviewKind } from "./scoring-spec.js";
/** The live host coordinates a seeding run authenticates and talks to. */
export interface BenchAccess {
/** Gitea instance base URL, without the /api/v1 suffix. */
apiUrl: string;
token: string;
}
/** Owner and name of a throwaway repository on the host. */
export interface RepoCoords {
owner: string;
repo: string;
}
/**
* Resolve the host and token for a benchmark run by reusing gitea-axi's own
* credential discovery: list the tea logins, pick the named one exactly as the
* CLI does, and mint the token through tea's git-credential helper. No new secret
* handling is introduced — the benchmark rides the same path the product ships.
*/
export async function resolveBenchAccess(deps: CliDeps, loginName: string): Promise<BenchAccess> {
const logins = await listLogins(deps);
const login = selectLogin(logins, loginName, undefined);
const host = new URL(login.url).hostname;
const token = await getToken(deps, login, host);
return { apiUrl: login.url.replace(/\/+$/, ""), token };
}
/**
* One authenticated Gitea API round-trip; returns the raw response unchecked.
* Exported so the self-review probe (self-review.ts) can inspect a non-2xx
* response — a host that forbids self-approval — without it being thrown.
*/
export async function request(
access: BenchAccess,
method: string,
path: string,
payload?: unknown,
): Promise<Response> {
return fetch(`${access.apiUrl}/api/v1${path}`, {
method,
headers: {
authorization: `token ${access.token}`,
...(payload !== undefined ? { "content-type": "application/json" } : {}),
},
body: payload !== undefined ? JSON.stringify(payload) : undefined,
});
}
/** Fail on any non-2xx response, surfacing the method, path, status, and body. */
async function requireOk(res: Response, method: string, path: string): Promise<Response> {
if (!res.ok) {
throw new Error(`${method} ${path} failed (${res.status}): ${await res.text()}`);
}
return res;
}
/**
* Issue a request and require a 2xx, returning the parsed JSON body. Exported so
* the post-run snapshot capture (snapshot.ts) reads the live repository through
* the same authenticated round-trip the seed writes through.
*/
export async function send<T>(
access: BenchAccess,
method: string,
path: string,
payload?: unknown,
): Promise<T> {
const res = await requireOk(await request(access, method, path, payload), method, path);
return (await res.json()) as T;
}
/** The single available user: the account the token authenticates as. */
export async function currentUser(access: BenchAccess): Promise<string> {
const me = await send<{ login: string }>(access, "GET", "/user");
return me.login;
}
/**
* Create a fresh, private, auto-initialized throwaway repository under the
* authenticated user and return its coordinates. Each call mints a distinct name,
* so trials never collide.
*/
export async function provisionRepo(
access: BenchAccess,
name = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
): Promise<RepoCoords> {
const owner = await currentUser(access);
await send(access, "POST", "/user/repos", {
name,
auto_init: true,
default_branch: "main",
private: true,
});
return { owner, repo: name };
}
/**
* Best-effort deletion of a throwaway repository. `request` does not throw on a
* non-2xx, so a token lacking delete scope is ignored silently; the try/catch
* additionally tolerates a network-level failure. Cleanup must never fail a run.
*/
export async function deleteRepo(access: BenchAccess, coords: RepoCoords): Promise<void> {
try {
await request(access, "DELETE", `/repos/${coords.owner}/${coords.repo}`);
} catch {
// Swallow network-level failures; a non-2xx never reaches here.
}
}
/** Colours compare equal regardless of a leading `#` or letter case. */
function normalizeColor(color: string): string {
return color.replace(/^#/, "").toLowerCase();
}
interface GiteaLabel {
id: number;
name: string;
color: string;
description?: string;
}
/**
* Reconcile the repository's labels to the plan, keyed by name: create a missing
* label, patch one whose colour or description drifted, and leave a matching one
* untouched. Returns the name→id map the issue and pull-request steps need to
* apply labels.
*/
async function ensureLabels(access: BenchAccess, coords: RepoCoords): Promise<Map<string, number>> {
const base = `/repos/${coords.owner}/${coords.repo}/labels`;
const existing = await send<GiteaLabel[]>(access, "GET", `${base}?limit=100`);
const byName = new Map(existing.map((label) => [label.name, label]));
for (const label of SEED_PLAN.labels) {
const found = byName.get(label.name);
if (!found) {
const created = await send<GiteaLabel>(access, "POST", base, {
name: label.name,
color: label.color,
description: label.description ?? "",
});
byName.set(created.name, created);
} else if (
normalizeColor(found.color) !== normalizeColor(label.color) ||
(found.description ?? "") !== (label.description ?? "")
) {
await send<GiteaLabel>(access, "PATCH", `${base}/${found.id}`, {
color: label.color,
description: label.description ?? "",
});
}
}
return new Map([...byName].map(([name, label]) => [name, label.id]));
}
/** Replace an issue-or-pull-request's applied labels with exactly the given ids. */
async function applyLabels(
access: BenchAccess,
coords: RepoCoords,
number: number,
names: string[],
labelIds: Map<string, number>,
): Promise<void> {
const ids = names.map((name) => labelIds.get(name)).filter((id): id is number => id !== undefined);
await send(access, "PUT", `/repos/${coords.owner}/${coords.repo}/issues/${number}/labels`, {
labels: ids,
});
}
/**
* Add each item whose body is not already present at `base`, keyed by body. Both
* comments and reviews live at a single endpoint that lists and creates at the
* same path, so one reconciler serves them: it lists what is there, then posts
* only the items whose body is missing — which is what makes re-seeding a no-op.
*/
async function addMissingByBody<T>(
access: BenchAccess,
base: string,
items: T[],
bodyOf: (item: T) => string,
payloadOf: (item: T) => unknown,
): Promise<void> {
if (items.length === 0) {
return;
}
const existing = await send<{ body: string }[]>(access, "GET", base);
const present = new Set(existing.map((entry) => entry.body));
for (const item of items) {
if (!present.has(bodyOf(item))) {
await send(access, "POST", base, payloadOf(item));
}
}
}
/** Add each comment body not already present on an issue or pull request. */
async function ensureComments(
access: BenchAccess,
coords: RepoCoords,
number: number,
bodies: string[],
): Promise<void> {
await addMissingByBody(
access,
`/repos/${coords.owner}/${coords.repo}/issues/${number}/comments`,
bodies,
(body) => body,
(body) => ({ body }),
);
}
interface GiteaIssue {
number: number;
title: string;
}
/**
* Reconcile one plan issue, keyed by title: create it if absent, then declare its
* body, state, applied labels, and assignee presence (the single user or nobody)
* and add any missing comments. Every field is set to the desired value, so the
* step is idempotent whether the issue was just created or already seeded.
*/
async function ensureIssue(
access: BenchAccess,
coords: RepoCoords,
user: string,
issue: SeedIssue,
labelIds: Map<string, number>,
byTitle: Map<string, number>,
): Promise<void> {
const base = `/repos/${coords.owner}/${coords.repo}/issues`;
let number = byTitle.get(issue.title);
if (number === undefined) {
const created = await send<GiteaIssue>(access, "POST", base, {
title: issue.title,
body: issue.body,
});
number = created.number;
byTitle.set(issue.title, number);
}
await send(access, "PATCH", `${base}/${number}`, {
title: issue.title,
body: issue.body,
state: issue.state,
assignees: issue.assignToSelf ? [user] : [],
});
await applyLabels(access, coords, number, issue.labels, labelIds);
await ensureComments(access, coords, number, issue.comments);
}
/** The Gitea review event verb for each seed review kind. */
const REVIEW_EVENT: Record<ReviewKind, string> = {
comment: "COMMENT",
approved: "APPROVED",
"request-changes": "REQUEST_CHANGES",
};
/** Ensure the pull request's feature branch exists, creating it with its file. */
async function ensureBranch(
access: BenchAccess,
coords: RepoCoords,
pr: SeedPullRequest,
): Promise<void> {
const branchPath = `/repos/${coords.owner}/${coords.repo}/branches/${pr.headBranch}`;
const res = await request(access, "GET", branchPath);
if (res.ok) {
return;
}
if (res.status !== 404) {
await requireOk(res, "GET", branchPath);
}
await send(access, "POST", `/repos/${coords.owner}/${coords.repo}/contents/${pr.filePath}`, {
content: Buffer.from(pr.fileContent).toString("base64"),
message: `Seed ${pr.headBranch}`,
new_branch: pr.headBranch,
});
}
/** Add each review (matched by body) not already present on the pull request. */
async function ensureReviews(
access: BenchAccess,
coords: RepoCoords,
number: number,
reviews: SeedPullRequest["reviews"],
): Promise<void> {
await addMissingByBody(
access,
`/repos/${coords.owner}/${coords.repo}/pulls/${number}/reviews`,
reviews,
(review) => review.body,
(review) => ({ event: REVIEW_EVENT[review.kind], body: review.body }),
);
}
interface GiteaPull {
number: number;
title: string;
state?: string;
/** True once merged; a merged pull request cannot be reopened. */
merged?: boolean;
}
/**
* Reconcile one plan pull request, keyed by title: ensure its feature branch,
* open the pull request if absent, then declare its labels and add any missing
* comments and reviews. All content is authored by the single available user.
*/
async function ensurePullRequest(
access: BenchAccess,
coords: RepoCoords,
pr: SeedPullRequest,
labelIds: Map<string, number>,
byTitle: Map<string, GiteaPull>,
): Promise<void> {
await ensureBranch(access, coords, pr);
const base = `/repos/${coords.owner}/${coords.repo}/pulls`;
let number: number;
const existing = byTitle.get(pr.title);
if (existing === undefined) {
const created = await send<GiteaPull>(access, "POST", base, {
title: pr.title,
body: pr.body,
base: "main",
head: pr.headBranch,
});
number = created.number;
byTitle.set(pr.title, created);
} else {
number = existing.number;
// The ground truth declares every seeded pull request open. Reopen one that
// drifted closed (but never a merged one, which Gitea cannot reopen), so the
// seed reconciles state as declaratively as it does for issues.
if (existing.state === "closed" && existing.merged !== true) {
await send(access, "PATCH", `${base}/${number}`, { state: "open" });
}
}
await applyLabels(access, coords, number, pr.labels, labelIds);
await ensureComments(access, coords, number, pr.comments);
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);
const labelIds = await ensureLabels(access, coords);
const issuesPath = `/repos/${coords.owner}/${coords.repo}/issues?type=issues&state=all&limit=100`;
const existingIssues = await send<GiteaIssue[]>(access, "GET", issuesPath);
const issuesByTitle = new Map(existingIssues.map((issue) => [issue.title, issue.number]));
for (const issue of SEED_PLAN.issues) {
await ensureIssue(access, coords, user, issue, labelIds, issuesByTitle);
}
const pullsPath = `/repos/${coords.owner}/${coords.repo}/pulls?state=all&limit=100`;
const existingPulls = await send<GiteaPull[]>(access, "GET", pullsPath);
const pullsByTitle = new Map(existingPulls.map((pull) => [pull.title, pull]));
for (const pr of SEED_PLAN.pullRequests) {
await ensurePullRequest(access, coords, pr, labelIds, pullsByTitle);
}
await waitForSeedReady(access, coords);
return groundTruth(user);
}
/**
* The observable facts the smoke run checks, read back from live Gitea (not from
* the plan) so the assertion compares the real repository against the declared
* ground truth rather than the plan against itself.
*/
export interface SeedSummary {
labelNames: string[];
openIssueTitles: string[];
closedIssueTitles: string[];
selfAssignedIssueCount: number;
issuesWithCommentsCount: number;
pullTitles: string[];
labeledPullTitles: string[];
reviewedPullTitles: string[];
}
interface GiteaIssueSummary {
title: string;
state: string;
assignees: { login: string }[] | null;
comments: number;
}
interface GiteaPullSummary {
number: number;
title: string;
labels: { name: string }[] | null;
}
/** Read the live repository into the summary the smoke run asserts against. */
export async function readSeedSummary(
access: BenchAccess,
coords: RepoCoords,
): Promise<SeedSummary> {
const repo = `/repos/${coords.owner}/${coords.repo}`;
const labels = await send<GiteaLabel[]>(access, "GET", `${repo}/labels?limit=100`);
const issues = await send<GiteaIssueSummary[]>(
access,
"GET",
`${repo}/issues?type=issues&state=all&limit=100`,
);
const pulls = await send<GiteaPullSummary[]>(access, "GET", `${repo}/pulls?state=all&limit=100`);
const reviewedPullTitles: string[] = [];
for (const pull of pulls) {
const reviews = await send<{ body: string }[]>(
access,
"GET",
`${repo}/pulls/${pull.number}/reviews`,
);
if (reviews.length > 0) {
reviewedPullTitles.push(pull.title);
}
}
return {
labelNames: labels.map((label) => label.name),
openIssueTitles: issues.filter((i) => i.state === "open").map((i) => i.title),
closedIssueTitles: issues.filter((i) => i.state === "closed").map((i) => i.title),
selfAssignedIssueCount: issues.filter((i) => (i.assignees ?? []).length > 0).length,
issuesWithCommentsCount: issues.filter((i) => i.comments > 0).length,
pullTitles: pulls.map((p) => p.title),
labeledPullTitles: pulls.filter((p) => (p.labels ?? []).length > 0).map((p) => p.title),
reviewedPullTitles,
};
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { CliDeps } from "../src/deps.js";
import { resolveBenchAccess, type BenchAccess } from "./seed.js";
import { detectSelfReviewSupport } from "./self-review.js";
/**
* The self-review probe smoke tier: a single live validation that the capability
* probe runs end-to-end against a real host — provisioning a throwaway
* repository, seeding it, checking whether the authenticated user may approve
* their own pull request, and cleaning the repository up — and reaches a definite
* boolean verdict without throwing. The benchmark-harness spec designates the
* self-review probe, like seed provisioning, as validated by a smoke run against
* a real host rather than by mocks, since its value is the real API interaction.
* Like the seed smoke tier, this suite skips cleanly when GITEA_AXI_BENCH_LOGIN
* is unset, which counts as a pass.
*
* The verdict itself is host-configuration-dependent — some hosts forbid a user
* from approving their own pull request, some permit it — so the assertion is
* only that a definite boolean is reached, never which value it is.
*/
const login = process.env.GITEA_AXI_BENCH_LOGIN;
describe.skipIf(!login)("self-review smoke: capability probe", () => {
it(
"runs the self-review probe against the live host and returns a definite boolean verdict",
async () => {
const deps: CliDeps = {
env: process.env,
cwd: process.cwd(),
globals: { login },
};
const access: BenchAccess = await resolveBenchAccess(deps, login!);
const result = await detectSelfReviewSupport(access);
expect(typeof result).toBe("boolean");
},
180_000,
);
});

60
bench/self-review.ts Normal file
View File

@@ -0,0 +1,60 @@
// The self-review capability probe: whether the live host permits a user to
// approve or request changes on their own pull request. The single-user seed can
// always leave a comment-type review on its own pull request, but approve and
// request-changes are host-gated — Gitea can be configured either way — so the
// scored suite's two review tasks must be resolved against the real host before a
// sweep: promoted to approve/request-changes where self-review is permitted, left
// as comment reviews otherwise (see task-suite.ts).
//
// This is a live boundary, like seed.ts and snapshot.ts: its value is the real
// Gitea API interaction, so it is exercised by the smoke run rather than mocked.
import {
deleteRepo,
provisionRepo,
request,
seedRepo,
type BenchAccess,
type RepoCoords,
} from "./seed.js";
import { SEED_PLAN } from "./seed-plan.js";
/**
* Attempt an approving review on one of the pull requests authored by the single
* available user, returning whether the host accepted it. A 2xx means self-review
* is permitted; a 4xx (the host forbidding self-approval) means it is not. A
* network-level failure propagates rather than being read as "not permitted", so a
* transient error never silently downgrades the scored suite.
*/
export async function probeSelfReview(
access: BenchAccess,
coords: RepoCoords,
prNumber: number,
): Promise<boolean> {
const res = await request(
access,
"POST",
`/repos/${coords.owner}/${coords.repo}/pulls/${prNumber}/reviews`,
{ event: "APPROVED", body: "self-review capability probe" },
);
return res.ok;
}
/**
* Detect self-review support end to end against the live host: provision a
* throwaway repository, seed it to the ground truth (which creates the pull
* requests), probe an approval on the first seeded pull request, and delete the
* repository. The first pull request's number is deterministic — issues are seeded
* before pull requests in one shared number space — so it is the issue count plus
* one. Returns the probe's verdict for the suite builder to consume once per sweep.
*/
export async function detectSelfReviewSupport(access: BenchAccess): Promise<boolean> {
const coords = await provisionRepo(access);
try {
await seedRepo(access, coords);
const firstPullNumber = SEED_PLAN.issues.length + 1;
return await probeSelfReview(access, coords, firstPullNumber);
} finally {
await deleteRepo(access, coords);
}
}

Some files were not shown because too many files have changed in this diff Show More