Find a file
DuduPhudu 2c0fb7753c
fix(group): stop reporting what could not be measured as a measurement of zero (#3012)
* fix: surface unreadable group indexes and escape raw NUL bytes in source

Two independent diagnostics failures, both of which turn a real error into a
confident, benign-looking answer.

**Unreadable member repos (#3011).** `syncGroup` wrapped `initLbug` plus all
contract extraction for each member in a bare `catch {}` that pushed the repo
onto `missingRepos` and discarded the error. A LadybugDB storage-version
mismatch therefore surfaced as "repo not found", `group sync` printed
`0 contracts, 0 cross-links` and exited 0, and the existing contracts.json was
overwritten with an empty registry. The two states need different answers from
the operator — a missing repo must be indexed, an unreadable one is usually
version skew or a lock — so they are now separate:

- the caught error is logged with the repo, group path and lbug path
- `unreadableRepos` is tracked alongside `missingRepos` on `SyncResult`,
  persisted (optionally, so older registries still parse) on `ContractRegistry`,
  and threaded through `GroupService` sync/status
- `group sync` reports both before the cascade counts, since an unread repo is
  the likely explanation for a small or empty count
- `group status` reports unreadable repos separately; calling them "missing"
  actively misdescribed them
- when EVERY configured repo fails to open, the write is skipped: an extraction
  that read nothing is not evidence the group has no contracts, and replacing a
  good registry with an empty one loses data while reporting success

**Raw NUL bytes (#3010).** `sync.ts` and `free-call-fallback.ts` each used a NUL
as a join delimiter, written as a literal 0x00 instead of `\0`. Identical at
runtime, but it makes the file test as binary: `file(1)` reports `data`, ugrep
returns empty with exit 1 — indistinguishable from "no match", with no message —
and BSD grep replaces matching lines with "Binary file ... matches". A search
that should hit comes back as a confident "not present". Both now use the escape,
and a unit test fails on any raw control byte in src/ so it cannot silently
return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(hygiene): guard every tracked source file against a raw NUL, not just src/

The guard added with the NUL escapes only scanned gitnexus/src for .ts/.tsx.
Neither prior recurrence of this defect in this repo was in that scope:
b620773b1 was gitnexus/bench/cpp-qualified-ns/measure.mjs and 38d737bb5 was a
fixture under gitnexus/test. A guard that cannot see where the bug has actually
landed twice is not a guard.

Drive the file list from `git ls-files` at the repository root over
.ts/.tsx/.js/.jsx/.mjs/.cjs/.mts/.cts — 2483 files instead of 828 — and split
the byte class, which is the part that matters:

  - 0x00 is a hard failure repo-wide. It is the byte git's binary heuristic
    keys on, so it is the one that costs a file its diff (and, on the base side
    of a PR, its inline-comment anchors and its three-way merge).
  - The wider C0 class stays scoped to gitnexus/src. A repo-wide scan finds
    exactly one hit, test/unit/logger.test.ts:146, and that 0x1b is a
    legitimate ANSI-escape fixture that is the subject of the test. Widening
    this half would go red on day one.

Read Buffers and scan bytes instead of decoding each file to latin1, through a
bounded read pool: 1.5 s for 2483 files, against 8-21 s previously for 828.

Add a negative fixture — a planted 0x00 and 0x1b run through the same scanning
helper — so a future refactor of the collector cannot leave a permanently green
guard, plus an assertion that the collected set still reaches bench/, test/ and
.mjs, which goes red if the scope is ever narrowed back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): report a cross-repo impact built from an incomplete bridge as truncated

When a sync cannot read a member repo, that repo's contracts and every
cross-link touching them are simply absent from bridge.lbug. Nothing in the
impact walk could notice: the only incompleteness channel on a
GroupImpactResult is truncationFields(), which is driven by fan-out state
(truncatedRepos / localPartial / fanoutTimedOut), and a repo missing from the
bridge sets none of them.

So `group impact` on a symbol whose one downstream consumer lives in an
unreadable repo returned `{ cross: [], truncated: false }` — "complete: nothing
in another repo depends on this". That is a wrong answer, not an empty one, for
a tool an agent uses to license a delete or a rename.

BridgeMeta now records unreadableRepos alongside missingRepos, writeBridge
persists it when non-empty, and runGroupImpact folds a non-empty
unreadableRepos ∪ missingRepos into truncated / riskEpistemic: 'lower-bound',
naming the repos in truncatedRepos.

The reason is a new 'incomplete-sync' rather than the existing 'partial'
because the remedy differs: 'timeout' and 'partial' are runtime limits the same
query can clear on a retry, while this one clears only when `gitnexus group
sync` succeeds. Runtime limits still take precedence when both apply, since
those are what the caller can act on immediately.

The risk VALUE is never clamped down — mergeRisk is monotone in the traversed
crossing count, so an incomplete bridge can only under-report. Marking the floor
is what makes that legible.

Both shape changes are additive and optional, so a bridge written before this
still reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): say truthfully what a sync did to contracts.json

Review follow-ups to the unreadable-repo diagnostics. Every item below is a
place where the code still answered a question it could not answer.

1. The CLI announced a write it did not perform. `group sync` printed "Wrote
   contracts.json (0 contracts, 0 cross-links)" unconditionally, including on
   the path that deliberately left the file alone. SyncResult now carries
   registryOutcome ('written' | 'preserved' | 'not-attempted'), the CLI prints
   from it, and group_sync returns it so an agent that calls group_sync then
   group_contracts can tell why the counts disagree.

2. Refusing to write anything on total failure threw away the diagnostic
   describing the run that just happened. `group status` reads contracts.json
   from disk, so the operator who saw the sync fail and ran status to find out
   why read the PREVIOUS sync's file: no unreadable list, an old lastSync, a
   healthy-looking group — or worse, the previous run's unreadable list
   presented as this one's. The skip is now targeted: contracts, crossLinks,
   repoSnapshots and generatedAt carry forward verbatim, only missingRepos and
   unreadableRepos are refreshed. generatedAt stays put because it dates the
   contracts, which are still the previous run's. With no prior file, or an
   unparseable one, nothing is written at all.

3. Per-repo extraction is now all-or-nothing. Extractors run in sequence and
   any one can throw; appending each one's results straight to autoContracts
   meant a repo whose HTTP extractor succeeded and whose gRPC extractor then
   failed contributed a partial set to the registry, while the same run told
   the operator that repo's "contracts are omitted from this sync".

4. readRegistry gains an opt-in strict mode, and syncGroup uses it. The lenient
   `catch { return []; }` converted "I could not read the registry" into "no
   repo is registered": every configured repo then resolved to MISSING, the
   total-failure guard stayed off (it needs a load error), and a good
   contracts.json was replaced by an empty one at exit 0. That is an unreadable
   condition reported as missing, one frame above the code this branch fixes.
   The default stays lenient for the other nine callers; ENOENT stays lenient
   in both modes.

5. Absence of unreadableRepos keeps meaning "not recorded". The loader spreads
   the key in only when present instead of defaulting to [], and getStatus
   passes undefined through, so a legacy registry no longer reads as "the last
   sync found none unreadable". getStatus also gates both list fields on
   Array.isArray: it reads through readContractRegistry, which is a bare
   JSON.parse cast, so a corrupt string in either slot used to reach
   cli/group.ts and die in .join(', ') — the command whose job is explaining an
   unreadable thing, crashing on one.

6. Smaller, same theme: the per-repo warning passes the Error itself rather
   than err.message, so pino keeps the stack; the total-failure warning no
   longer fires on a dry run, where it described a file the call was never
   going to touch and which need not exist; the status table's MISSING legend
   stops re-conflating the two states; the sync warning drops its
   GITNEXUS_LOG_LEVEL=warn hint, which would only have suppressed output (pino
   emits warn at the default info level, so the reason was already printed);
   and the group_sync tool description and its idempotency comment now describe
   what the tool actually does.

Testing. The original four cases could not see the change they were named
after. Mutation testing showed two survivors: dropping the ===
configuredRepoCount conjunct, which turns "every repo failed" into "any repo
failed" and would silently freeze contracts.json for a group where one of five
repos is skewed; and deleting both logger.warn calls, the stated purpose of the
change. Both survived because every case configured exactly one repo and
nothing read the log. There is now a two-repo case running the real per-repo
loop, an all-missing case, a _captureLogger assertion on the level 40 record,
partial-extraction cases, and strict-read cases. All five mutants are killed,
each by exactly one test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): tighten the registry list gates and stop naming a truncation reason on complete results

Three follow-ups from the check bot's pass over the previous commits.

1. `detect.includes` was missing from both group-sync test fixtures, so they did
   not satisfy the `GroupConfig` they claim to construct. It went unnoticed
   because `tsconfig.json` is src-only; `tsconfig.test.json` reports it. The
   older of the two fixtures carried the gap in from the original commit.

2. `runGroupImpact` named its truncation reason in a variable computed before
   the truncated check, so on a fully complete result the variable read
   'incomplete-sync'. `truncationFields` discards the reason when `truncated` is
   false, so nothing surfaced — but a value that is wrong whenever it is unused
   is a trap for the next reader. Computed inline at the one call site that can
   consult it, which is also how the neighbouring call sites are written.

3. `Array.isArray` alone let a corrupt registry through. `['app/backend']` and
   `[{repo:'x'}]` are both arrays, and only the second reaches `cli/group.ts`'s
   `.join(', ')` — as `[object Object]`, a measurement the operator can read but
   cannot act on. Both readers now go through one `recordedRepoList` helper that
   requires an array of strings; anything else is "not recorded", the same as
   absent. Two more rows in the corrupt-value table cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): keep readRegistry's signature, and stop describing unreadableRepos as index-only

Two items from the check bot's blocking pass.

1. `readRegistry` gained an optional `opts` parameter last commit. That is
   source-compatible — every zero-argument call still compiles and behaves
   identically — but the contract check treats any parameter-list change on a
   symbol with outside callers as a break, and it is right that the safest
   version of this change touches that signature not at all. The strict read is
   now its own export, `readRegistryStrict()`, over a shared private body.
   `readRegistry()` is byte-identical to what it was; `syncGroup` is the only
   caller of the strict one, and the mode is legible at the call site instead of
   hiding in an options bag.

2. `unreadableRepos` is described everywhere as "the index could not be opened".
   That was accurate before this branch and is not now: making per-repo
   extraction all-or-nothing means a repo also lands there when an extractor
   throws partway with the index open fine. The two belong in one bucket
   because the consequence is one thing — none of that repo's contracts are in
   this sync — but the docs have to say so, or an operator reads `unreadableRepos`
   as a storage diagnosis and goes looking at LadybugDB for an extractor bug.
   Corrected on `ContractRegistry`, `BridgeMeta`, `SyncResult`, the `group_sync`
   tool description, and the `group sync` console output, which now says
   "Could not extract contracts from" rather than "Could not read the index for".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(cli): stop calling an unreadable registry an old one in group status

`getStatus` reports `unreadableRepos` as `undefined` for two different reasons:
the field is genuinely absent, or it held something that was not a list of repo
paths and the shape gate declined to guess. The status line named only the
first — "registry predates this field" — so a corrupt value read as a merely
old registry.

That is the same shape of wrong answer this command exists to stop giving: a
condition we could not read, presented as a benign one we understand. The line
now names both, and asks for a sync either way, which is the fix in both cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): close the three fail-open paths left on the safety boundaries

Follow-ups from the re-review of 31c2b6e81. All three of its blocking findings
reproduce; each is a place where unknown state still resolved to a confident
benign answer, which is the one thing this branch exists to stop.

1. Strict registry reading accepted malformed rows. `[{}]` is a JSON array, so
   it passed the shape check: every configured repo then failed to resolve into
   `missingRepos`, none produced a load ERROR, the total-failure guard stayed
   off, and a good contracts.json was replaced with an empty one at exit 0 —
   the same fail-open the strict mode was added to close, one level down from
   the file to the rows inside it. Strict mode now requires `name`, `path` and
   `storagePath` on every row and rejects the WHOLE registry if any row fails.
   Rejecting rather than filtering is the point: dropping bad rows would report
   the repos they name as unregistered, which is the same wrong answer again.
   `indexedAt` / `lastCommit` are deliberately not required — callers already
   default them, so demanding them would trade a fail-open for a fail-shut on a
   legitimate legacy registry.

2. A failed bridge publication could make impact look complete. `writeBridge`
   swaps `bridge.lbug` and writes `meta.json` as two operations, and this branch
   made that meta load-bearing: `runGroupImpact` derives its truncation fields
   from it. A sync interrupted between the two steps therefore left a NEW bridge
   beside the PREVIOUS sync's metadata, and an impact query read that as
   "complete". Fixed from both ends. The write path removes the old meta before
   the swap, so the window leaves metadata ABSENT rather than stale. The read
   path treats absent-or-unparseable meta (`version: 0`) as unknown provenance
   and reports a floor, which also covers the caught `writeBridge` failure in
   `syncGroup`. Over-reporting truncation on a bridge that is actually fine is
   the safe direction, and the next successful sync clears it.

3. `preserved` was returned when there was nothing to preserve. On a group's
   first all-unreadable sync the outcome was set before the prior registry was
   read, so the CLI told an operator "the contracts from the previous sync are
   preserved" about a file that had never existed. Split out as
   `no-prior-registry`, with its own console message.

Also widened the NUL guard to the source languages it claimed to cover. The
commit that added it said "every tracked source file" while the collector
stopped at the JS/TS family, so a raw NUL in tracked Python, Java, Go, Rust,
C/C++, Ruby, PHP, Kotlin, Swift, C# or shell would still have turned those files
binary unnoticed. Measured before widening: 2315 non-JS tracked source files,
zero hits, so this was an unforced gap rather than a tradeoff. A planted `.py`
fixture and a collector-coverage assertion keep it honest.

Every fix is mutation-verified: reverting each one individually turns its own
tests red (3, 2, 2, 1 and 1 failures respectively), and all pass together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): record the empty unreadable measurement instead of dropping it

Both writers omitted `unreadableRepos` when it was empty, which made the
tri-state this branch introduced unreachable in its most common case.

`ContractRegistry.unreadableRepos` is optional on the TYPE so a registry written
before the field existed still parses, and absence there means "not recorded".
But a sync that read every repo successfully HAS measured it, and `[]` is that
measurement. Dropping it collapsed "measured, none" into "never recorded", so
after every clean sync `gitnexus group status` printed

    Last sync unreadable repos: not recorded
    (the registry predates this field, or its value could not be read)
    Re-run `gitnexus group sync` to record it.

about the sync that had just succeeded. The distinction is only worth having if
the writer commits to it, so both `contracts.json` and the bridge's `meta.json`
now record the field whenever the sync supplied it, `[]` included.

The check bot found this on the bridge writer and attributed the consequence to
`group status`. The consequence is real but it is not the bridge's: `getStatus`
reads `contracts.json` and never touches `BridgeMeta`, whose only consumer is
`runGroupImpact` — where absent and empty are already equivalent. So the
user-visible half was in the registry writer, one file over from where it was
reported, and both are fixed.

Also fills in `DetectConfig.includes` (and `workspace_deps`) across the group
test fixtures that predate those fields. These are pre-existing on main and are
a no-op at runtime — `undefined` and `false` are both falsy at the gate — but
they are the same defect the bot flagged as an error in the new fixtures, and
`tsconfig.test.json` reported eleven of them. That file is not in CI, which is
why they survived; the group tree is now clean of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* test(group): stop two bridge-metadata tests claiming coverage they do not have

Both were named for the swap window and neither injects a swap failure.

"drops the previous meta.json before swapping the database file" runs two
successful writeBridge calls. Its assertions hold with the removal in either
position, because writeBridge overwrites meta.json at the end regardless — so
it cannot pin the ordering it is named for. Renamed to what it does cover, the
successful-rebuild replacement, with the limit stated in the body rather than
left for the next reader to discover.

"leaves NO meta.json when the swap fails partway" removes the file by hand
after a successful write, so it exercises readBridgeMeta's missing-file
contract, not writeBridge. That contract is worth pinning on its own — version 0
is the signal runGroupImpact fails closed on — so the test stays, under a name
that says so.

The ordering itself is pinned in bridge-meta-swap-window.test.ts, which mocks
retryRename to throw on the bridge.lbug swap and asserts the previous sync's
metadata cannot survive it. Both renamed tests now point there, so the coverage
is findable from the place someone would look for it.

No production code changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* fix(group): pair bridge metadata to its database instead of deleting it

The previous commit closed the swap/metadata window by removing meta.json before
the database swap, so the window would fail to "absent" rather than "stale". That
was the wrong trade, and it destroyed recoverable state.

The old database's move to `.bak` sits inside a catch that swallows failures, not
just "no existing db". When that rename fails — a held read-only handle does this
on Windows, and a long-lived MCP server holds one — the failure is swallowed, the
following `tmp -> bridge.lbug` throws, and writeBridge exits with the OLD database
still in place and perfectly valid. Its metadata was already deleted. Cross-repo
impact then answers "we cannot say" for that group until some future sync
succeeds, and if the cause is a held handle or permissions there is no such sync.
A working feature, destroyed permanently to close a narrow window.

Deleting also only chose which way the window failed; it never closed it.

So destroy nothing, and make the pair self-describing instead: writeBridge stamps
the database's size and mtime into the metadata it writes, and
`bridgeMetaMatchesFile` lets a reader ask whether the two still belong together.
`runGroupImpact` treats a mismatch the same as absent metadata — provenance
unknown, report a floor. A metadata file left over from an earlier sync cannot
match a freshly renamed database, and a sync that fails before the swap leaves a
matching pair untouched. Metadata written before the stamp existed is
unverifiable rather than stale, and is accepted: failing those closed would mark
every pre-existing bridge incomplete, trading a narrow window for a repo-wide
regression.

The swap-window test now distinguishes the two failure shapes, because they want
different answers. When every rename fails the old database never moves, so the
surviving metadata still matches it and impact keeps answering from it. When only
the final rename fails the old database has already reached `.bak` and no
database is in place, so the metadata correctly matches nothing — and
`ensureBridgeReady` fails loudly on the absent file, which beats a silent floor.

Mutation-verified: reinstating the delete, neutering the pairing check, and
dropping the stamp each turn 2, 3 and 3 tests red respectively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV

* chore: keep TypeScript diffs readable after a NUL leaves the tree

Git decides a pair is binary when EITHER blob carries a NUL, and it only
sniffs the first 8000 bytes. `gitnexus/src/core/group/sync.ts` carried one
at byte 5132 on main. This branch removes it, but the base side still has
it, so the file renders as "Binary files differ" in the pull request: no
hunks, no inline comments, and no three-way merge — however clean the head
side is. A head-side byte guard cannot detect that, by construction, since
it only ever sees the working tree.

Setting the `diff` attribute stops the heuristic from hiding the change.
It does not mark the files binary, does not imply `text`, and does not
change how blobs are stored, normalized, or checked out — the root
`* text=auto eol=lf` still governs all of that. It affects diff generation
and rendering only.

Locally this turns the branch's own sync.ts diff from `Bin 17612 -> 25346
bytes` into 154 insertions and 16 deletions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): answer "provenance unknown" for malformed bridge metadata

`readBridgeMeta` guarded the read and the parse but not the SHAPE of what it
parsed, then cast the result. `runGroupImpact` spread both repo lists straight
into a Set, so a `meta.json` whose `missingRepos` held an object threw a
TypeError out of the entire cross-repo query — and threw it from a point after
`ensureBridgeReady` had taken the bridge lease and before the `try` whose
`finally` releases it, so every such query also leaked a refcount the cached
handle could never get back. A malformed file is a reason to answer "we cannot
say", never a reason to crash the question.

The shape gate now lives where the metadata is read, mirroring the one
`service.ts` already applies to the registry's copies of these same two lists.
Each list is judged independently: a garbage `unreadableRepos` no longer
discards a `missingRepos` that was genuinely measured. A list that was present
but unusable is dropped rather than normalized to `[]`, because an unreadable
value is not a measurement of zero — the new reader-side `repoListsUnreadable`
carries that distinction, and `runGroupImpact` folds it into the same
provenance-unknown verdict it already reaches for `version: 0` and for
metadata that does not pair with the database beside it.

A root that is not an object is closed too. `JSON.parse` succeeds on `null`,
`7` and `[]`; the first threw on `.version`, and the other two read `undefined`
and sailed through the version gate as if the bridge had been vouched for.

Both provenance values moved inside the protected region and are initialized
fail-closed, so a future throw between the lease and the walk releases rather
than wedges.

`repoListsUnreadable` is reader-side only: the sole `writeBridgeMeta` call site
builds a fresh literal, so nothing persists it and no schema version moves.

Mutation-verified: reverting the shape gate alone turns 4 tests red — the three
malformed-list scenarios plus the handle-release regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(storage): reject registry rows that cannot identify a repo

The strict read's row gate gave `typeof v === 'string'`, and `typeof '' ===
'string'`. A row whose `name` was blank therefore passed as resolvable, then
matched nothing in `defaultResolveHandle` — putting every configured repo in
`missingRepos` and presenting an unusable registry as a clean answer about an
empty one. That is the same unreadable-as-missing fail-open the strict mode
exists to close, one level further in. A blank `storagePath` is worse than
useless: it joins to a relative `lbug` under the current directory, so the sync
opens an index that is not the repo's.

Both now have to be non-blank after trimming. `path` stays at the bare string
check, on the same reasoning that already exempts `indexedAt`/`lastCommit`:
require only what resolution depends on to IDENTIFY the repo. This gate rejects
the whole registry and the registry is machine-wide, so a field tightened past
what identification needs would let one blank value in one row break every
group sync on the machine — including groups whose repos all resolve. A blank
`path` still yields a working handle; `defaultResolveHandle` does read it, but
only for the pool id and `repoPath`, neither of which decides whether the row
names a repo.

The error now says what is actually wrong instead of naming three fields that
are all present.

Mutation-verified in both directions: dropping the trim turns the three
rejection tests red, and applying the wider fix that was considered and
declined — tightening `path` too — turns exactly the counter-case red, so that
test genuinely pins the narrow reading rather than passing either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): bound the per-repo contract staging append

`autoContracts.push(...repoContracts)` passes every staged contract as a
separate argument, and the engine caps how many arguments one call may take.
That cap is a function of the host's available stack, so it is a different
number on every machine — this one accepts a 125k-element spread and dies at
150k.

The spread itself is not new; what it carries is. Before staging, this line
appended a single extractor's output as it came back. Staging made it carry the
whole repo's, which is enough for a large repo to raise `RangeError: Maximum
call stack size exceeded` on the one line whose job is to commit work that just
succeeded. The throw lands in the catch below, so the sync reports a repo whose
extractors all ran cleanly as one whose index could not be read — a crash
wearing the costume of a diagnostic.

A bounded loop replaces it: the count a repo can stage is now bounded by memory
rather than by how much stack the process happened to get.

The guard is structural, not size-based, and deliberately so. A "make the
fixture big enough to crash" test passes against unfixed code on any host with
a larger stack, which is exactly the guarantee a regression gate cannot give
up. It walks the AST and locates the region by role — the `const` staging
buffer typed `StoredContract[]`, then the extractor `try` that is a direct
statement of the block declaring it — so renaming either identifier keeps it
pointed at the same code. `.apply()` is rejected alongside spread, being the
same hazard in different syntax.

Direct statements only, because `syncGroup` wraps this whole section in its own
try/finally for the lease sweep, and that ancestor reads the buffer too.
Matching any enclosing `try` pulls in the entire function body — including the
two windowed-manifest spreads, which are bounded by the window size and are not
what this fixes.

Mutation-verified in both directions: restoring the spread turns the gate red
naming that line alone; deleting a manifest-window spread, and separately
adding a third one, both leave it green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): keep unreadable repos out of manifest contracts too

Per-repo staging closed one door: a repo whose extractor threw contributes
nothing through the direct path. Deferred manifest resolution was a second
door, still open. It derives its known-repo set from the resolved-handle map,
which kept an entry for a repo the same run had already declared unreadable —
so the sync re-opened that index and resolved symbols against a database it had
just told the operator it could not read.

Deleting the handle in the catch stops the re-open, but it does not satisfy
R2 on its own: `ManifestExtractor` resolves both endpoints of a link and emits
a contract for each, and for an endpoint with no executor that contract is
still emitted with a synthetic UID. The registry ended up naming a repo the
same run reported unreadable.

So the emitted output is filtered by ENDPOINT, not by link. Dropping the whole
link would delete the healthy partner's contract as well — a repo losing its
own output because a neighbour's index would not open, which is wider than the
requirement and destroys good data to suppress bad. A cross-link is different:
it asserts something about a pair, so if either end is unreadable there is
nothing left to anchor it to, and a half-anchored link is exactly the
confident-about-what-it-could-not-read answer the registry must not give.

Deleting the handle also changed what the operator gets told, so the warning is
split. An unreadable repo IS configured; letting it fall into the "references
repos not in config.repos" branch states something false and sends the reader
to edit group.yaml for a problem only re-indexing fixes. It now gets its own
message naming what was actually omitted.

Mutation-verified four ways: reverting the endpoint filter turns three
scenarios red; the over-broad whole-link variant turns the healthy-partner
scenario red and nothing else; removing the handle delete turns the
no-re-open scenario red; and reverting the warning split turns the operator-
message scenario red. Every assertion reads the written contracts.json rather
than the in-memory result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(group): keep readBridgeMeta's signature stable across the shape gate

The shape gate landed by widening the return type to a reader-only
`ReadBridgeMeta extends BridgeMeta`. That is source-compatible — a covariant
return, one added optional field, every existing caller unaffected, typecheck
and suite clean — but the contract check reads it as a changed signature with a
caller left behind, and blocks the merge on it. This branch already hit the
same wall on `readRegistry` and settled it the same way: leave the signature
alone and make the difference legible some other way.

So the flag moves onto `BridgeMeta` itself as an optional, documented,
never-persisted field, and `readBridgeMeta` goes back to the exact signature
its callers already compile against.

That is the better shape here anyway. The reader-only subtype would have split
the validation two ways: `openBridgeDbReadOnly` and `bridgeExists` both gate on
`meta.version`, and the normalization that comes with the gate is what stops a
`version: null` in a hand-edited meta.json from reading as `undefined` and
sailing through `version > 0` as though the bridge had been vouched for. One
type keeps all three callers behind the same guard.

Nothing persists the flag: `writeBridgeMeta`'s only caller builds a fresh
literal, so it cannot round-trip to disk.

No behavior change — pure type restructuring. 927 tests pass, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): stop treating a half-written bridge stamp as a verified match

`bridgeMetaMatchesFile` joined its two `undefined` checks with `||`, so
metadata carrying a size and no mtime — or the reverse — returned `true`, the
same answer it gives a fully verified pair.

A stamp is a PAIR. Both halves absent is the legacy shape: metadata written
before stamping existed, which cannot be verified either way and is accepted
deliberately, because failing it closed would mark every pre-existing bridge
incomplete until re-synced. Exactly one half present is not that. Something
wrote a stamp and did not finish, which is precisely the condition stamping was
added to detect — so the check handed back "verified" for the one shape that
most deserves suspicion, and a cross-repo impact query built on it would report
a confident answer about a database its metadata cannot vouch for.

The two states are now separated: neither half present accepts, exactly one
rejects as provenance-unknown, both compare against the file as before.

Found by the repository's own contract check, not by the plan.

Mutation-verified: restoring the `||` form turns both half-stamp cases red
while the legacy and fully-stamped controls stay green, so the pair genuinely
pins the distinction rather than passing either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): pair unstamped bridge metadata by write order, before any open

Unstamped metadata was waved through: `bridgeMetaMatchesFile` returned "matches"
for any pair with no stamp to check, so the stale-meta-beside-a-new-database
window stayed open for every bridge written before stamping existed, and
`runGroupImpact` spent that metadata's completeness as fact.

`writeBridge` renames the database into place and writes the metadata after, so
`meta.mtime >= db.mtime` holds for any pair written together — including by
builds that predate the stamp. A database strictly newer than the metadata
beside it can only come from a swap whose metadata write did not land. That is
the fallback now. It is a heuristic on write order, not proof of provenance, and
it is wrong in two directions: a stale metadata file touched after the swap
still reads as paired, and a pair whose clock stepped backwards between the two
writes reads as unpaired. Both are recorded at the code; the second is the safe
direction. Equality counts as paired, or a coarse-granularity filesystem would
reject every legacy bridge for a reason that is about the filesystem.

The verdict is now taken in `ensureBridgeReady` BEFORE the database is opened,
and carried on the metadata rather than recomputed afterwards.

That ordering is load-bearing, not tidiness. Impact and trace both open the
bridge and only then ask about provenance, so on any platform or LadybugDB
build where a read-only open advances the file's mtime, every pre-stamp bridge
would report provenance-unknown from its first query onward — the exact
repo-wide regression this rule was chosen to avoid, arriving as a silent
downgrade rather than an error. It does not happen on Linux, which was measured.
It cannot be measured on Windows: pinning it by really opening the database
needs an in-process write→read reopen of the same bridge.lbug, which is a
documented limitation there. Rather than ship a Windows-skipped test and leave
the assumption unverified on the platform whose file semantics are most likely
to differ, the check moved ahead of the open so no platform has to be trusted.

The new guard forces the hostile case on every platform: the open is stubbed to
advance the database's mtime, and the verdict must still be "paired". It is
registered in the cross-platform list so the Windows and macOS shards run it,
and it has a control so it cannot pass vacuously.

Two existing fixtures mocked `readBridgeMeta` to return a stamped-era version
while never writing a meta.json — a state production cannot reach, since a
non-zero version can only come from a file that exists. They now write the
metadata their own mock claims to have read, rather than the helper being
loosened to accept metadata it cannot stat.

Mutation-verified twice: reverting the write-order branch turns both rejection
cases red while all four legacy-accept cases stay green, and moving the pairing
call back after the open turns the ordering guard red on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(group): compute cross-repo completeness in one place

Three surfaces can return a partial cross-repo answer — impact, trace, and the
contract listing — and each decided for itself whether it was complete. Impact
carried the structured triple; trace said it in prose, if at all. An agent
reading a not-found trace had no machine-readable way to tell "there is no
path" from "there may be a path in a repo this sync could not read", which is
the difference between an answer and a floor.

`crossRepoCompleteness` is now the one computation, and its input deliberately
does not name where any of it came from. `BridgeMeta` is not in the signature
and must not be: `groupContracts` answers the same question from contracts.json
and never opens a bridge, so `version`, `repoListsUnreadable` and
`pairedWithDatabase` do not exist on that path. Each caller derives its own
`provenanceUnknown` — the bridge callers through `bridgeProvenanceUnknown`,
which stays separate for exactly that reason — and passes the boolean in.

Scope arrives as a predicate rather than a repo list or a subgroup, so
narrowing a query's scope stays a change to one argument at the call site.

The trace results now carry `truncated` / `truncationReason` / `riskEpistemic`
like impact does. `notes` is untouched; it remains an addition to the machine
channel, never the channel.

One correction to the approach as written: it said to pass the trace's two
endpoint repos as the predicate, but a destination trace declares no `to`. It
asks where a call lands, so any member may hold the answer — and an unreadable
provider repo is precisely how "no outgoing ContractLink leaves this repo"
becomes a wrong answer rather than an empty one. Filtering that path to the
`from` repo would have reintroduced the bug this unit exists to close, so it
passes every repo and a test pins it.

Two pre-existing paths become consistent with the vocabulary as a result: a
crossing-capped result now reports `truncationReason: 'partial'` alongside the
`truncated` flag it already set, and the destination path's `ambiguous` returns
now report the cap its `ok` and `not_found` siblings already reported. Both are
additive — no field is removed, and no `truncated` flips from true to false.

`truncationFields` returns a discriminated union now, so `truncationReason`
reads without a fallback on the branch where it cannot be absent.

Mutation-verified: reverting the provenance fold alone — one line in the shared
helper — turns 8 tests red across both surfaces, 2 new trace scenarios and 6
existing impact ones, which is the point of there being one helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): narrow the incomplete-repo set to the query's declared scope

A subgroup-scoped impact query was marked a lower bound by repos it had
explicitly excluded. The fan-out already drops every neighbour outside the
subgroup, so those repos could not have contributed a crossing to the answer —
and a completeness marker that fires on results it does not describe is how a
caller learns to ignore the marker.

The scope is the query's DECLARED one, not the one the walk reached. An
incomplete repo's contracts are absent from the bridge by definition, so it is
never in the traversed set; filtering on what was traversed would empty the
intersection on every query and silently restore the fail-open this channel
exists to close.

Declared scope here is the subgroup PLUS the query's own repo, which the
approach did not account for. The walk starts from that repo's contracts in the
bridge, so when it is the repo the sync could not read there are no crossings
to find under any scope — and a subgroup excluding it would have turned that
vacuum into a confident "nothing depends on this", for a tool an agent uses to
license a delete. That case reported a floor before this change, so narrowing to
the subgroup alone would have been a regression. The union only ever widens the
in-scope set, so it cannot re-mark a repo the query excluded.

Membership goes through the existing `repoInSubgroup` in both clauses, `exact`
for the origin equality, rather than growing a second notion of what it means
for a repo path to be in scope.

Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1 — at depth 2 an out-of-scope
repo can sit between two in-scope ones — and that constraint is recorded at the
intersection.

Unscoped queries are byte-for-byte unchanged: `repoInSubgroup` answers true for
an absent subgroup, so the intersection is the whole set.

Mutation-verified: restoring the unfiltered predicate turns exactly the two
scoped cases red while the unscoped control and both in-scope guards stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): keep the preserved registry and the bridge from disagreeing

A total-failure sync refreshed contracts.json's diagnostic lists and left
meta.json alone. But meta.json, not contracts.json, is where runGroupImpact
reads completeness from — so the registry said "this sync could not read
app/backend" while a cross-repo query answered `{ cross: [], truncated: false }`.
Two surfaces describing the same run, one of them wrong, and the wrong one is
the machine-readable one an agent uses to license a delete.

The preserve path now refreshes the same two fields in the metadata. The
database stays untouched: it still holds the contracts being preserved, and
rebuilding it here would be the one write that could lose them.

Refreshing metadata is not free, though, and the obvious version of it is a
fail-open. The rewrite moves meta.json's mtime to now while bridge.lbug's stays
old, so an unstamped pair whose database is NEWER than its metadata — the shape
the write-order rule exists to reject — would come out of a preserve sync
passing the check. Writing "no stamp" does not help; the write-order comparison
is exactly what the moved mtime defeats. The verdict has to be recorded in the
metadata, because the refresh cannot avoid moving the mtime.

So `provenanceUnknown` is persisted whenever the existing pair does not already
check out, the existing stamp fields are carried through verbatim rather than
dropped, and `bridgeMetaMatchesFile` rejects the marker ahead of both the stamp
and the write-order heuristic. A pair that already matched is re-stamped
instead, which also upgrades a legacy unstamped-but-paired bridge to an exact
stamp. No preserve run can increase the number of pairs that pass the check.
The marker self-clears: `writeBridge` builds fresh metadata and never sets it.

`BridgeMeta` carries two reader-side fields documented as never persisted, and
this is the first code in the repo that reads metadata and writes it back. Both
are stripped explicitly before every write. `pairedWithDatabase` is the
dangerous one — persisted, it would tell every future reader the pair had been
verified — and a test seeds both on disk to pin that neither survives.

The write is not wrapped in a catch, unlike writeBridge on the success path.
There contracts.json is canonical and already written, so a stale bridge is a
recoverable degradation; here the write IS the guard against a confident wrong
answer, and swallowing its failure would reinstate the fail-open it closes.
`writeContractRegistry` above is unguarded into the same directory for the same
reason.

A group with neither file writes nothing: `readBridgeMeta` already answers
`version: 0` for an absent file, so a written one would say what the absence
already says while inventing state for a bridge that has never existed.

Mutation-verified three ways: dropping the marker write turns 6 red including
both laundering scenarios; moving the marker check below the stamp branches
turns the unstamped-laundering case red; removing the field stripping turns the
never-persisted test red. Each restored byte-exactly and re-verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): report group_contracts' completeness in the shared vocabulary

`group_contracts` returned contracts and cross-links and said nothing about
whether that listing was the whole story. An agent reading it after a sync that
could not open half the group got a confident-looking list with no way to tell
it was a floor — the same fail-open the impact path already closed, on a surface
that had no channel for the answer at all.

It now returns the registry's two diagnostic lists and the structured triple,
folded through the same helper the impact and trace surfaces use, so the three
cannot drift. The helper takes no `BridgeMeta` precisely so this path — which
reads contracts.json and never opens a bridge — can share it.

The three registry states stay distinguishable, which is the point:
  - key absent: the registry predates the field and has no opinion about which
    indexes opened, so the key is omitted rather than invented as `[]`, and the
    listing reports a floor. It cannot say which repos the sync failed to read,
    so it cannot claim to be complete.
  - key present and empty: measured, clean, not truncated.
  - key present and populated: the repos, and a floor.

`incompleteRepos` is dropped on this surface alone: both lists it derives from
are returned verbatim beside it, and a third name for the same repos is drift
waiting to happen.

The import is lazy, matching `groupImpact` and `groupTrace` in this same class.
`cross-impact.js` statically pulls the native LadybugDB binding through
`bridge-db.js`, and `service.ts` is loaded by every `gitnexus group` subcommand
including ones that touch no database.

One fix inside the same file that this unit forced: the registry loader gated
`missingRepos` with a bare `Array.isArray`, which admits `[{repo:'x'}]`. That
was inert while nothing read the list, but this change both returns it and folds
it into the completeness answer — so an unreadable value would have been printed
as a repo name and would have flipped `truncated` on garbage. It now uses the
same `recordedRepoList` gate `group status` already applies to the same field.
`missingRepos` has always been required, so unlike `unreadableRepos` it has no
"not recorded" state to preserve and an unreadable value degrades to empty.

Mutation-verified: reverting the fold alone turns 14 tests red and leaves the
control — the contract and cross-link payload this tool has always returned —
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): stop dropping group contracts' completeness fields on the way out

`group contracts --json` destructured `{ contracts, crossLinks }` from the
service payload and rebuilt an object from just those two. Everything else the
service returned was discarded on the way to stdout — so the completeness
fields the MCP tool now carries were invisible at the CLI, and the two surfaces
disagreed about the same registry.

It prints the payload whole now. A field added to the service reaches `--json`
without a matching edit here, which is the point: the re-serialized subset was
a second place that had to be remembered, and it was not.

The human-readable path gains the same signal in words. A listing built from a
sync that could not read part of the group shows counts that are a floor, not a
census, and it named neither fact. It now says so and names the repos when the
registry recorded them — and says the sync did not record which repos it could
read when it did not, because a listing that cannot say what it is missing is
still incomplete.

Mutation-verified: restoring the re-serialized subset turns the `--json` case
and the control red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): tell a missing registry entry apart from an unreadable one

`group status` printed MISSING for both "this repo has no row in the registry"
and "the registry itself could not be read", so an operator whose registry.json
was corrupt was told every repo was unregistered — and sent to re-register them
instead of to the one file that was actually broken.

The two are now separate. `missing` keeps its old meaning and still flags every
unusable repo, so an older consumer is unaffected; `unresolvable` is additive,
always present, and carries the reason that produced it.

This is the one caller that has to make that distinction, so it takes the
strict global-registry read. `readRegistry`'s `catch { return [] }` collapses a
malformed registry into an empty one, which is indistinguishable from a genuine
absence and is exactly what produced the wrong label. The cost is accepted
knowingly and recorded at the call site: the strict read rejects the whole
registry when any row fails to identify a repo, so one malformed row renders
every member unresolvable — including members whose own rows are fine. That is
the honest verdict, and it is reported as an unresolved state rather than a
clean one.

Choosing between the two labels needs to know whether a row exists at all,
which `registryIdentifies` answers by mirroring the two tiers the resolver
matches a bare group-config value on — registry name, case-insensitively, and
repo path. It deliberately stops short of the hashed-id and partial-name tiers:
those exist to be generous about what an operator typed, while this only picks
a label, and a looser match would relabel a genuine registry miss as an
unresolvable row — the same conflation this change removes, pointed the other
way.

The plan's third failure mode — a row that resolves but whose storage path
cannot be opened — turns out to be unreachable: `loadMeta` returns null on
every error and `checkStaleness` catches everything, so nothing after
`resolveRepo` inside the try can throw. The reachable per-repo case is
`resolveRepo` itself throwing, as it does for two registered clones sharing a
name, and that is what the tests drive end to end through the real CLI. The
code still handles the plan's case correctly if those helpers ever start
throwing.

Mutation-verified: reverting the split turns 6 unit and 2 CLI cases red while
both controls — a genuine miss, and a healthy group — stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): say what the preserve path actually does to contracts.json

The sync summary announced "Did NOT write contracts.json" on the branch that
writes it. The preserve path rewrites the file — keeping the previous sync's
contracts and cross-links, replacing only the two diagnostic lists — so an
operator who checked the mtime and found it moved was told the opposite of what
had happened, on the command this PR exists to make legible.

It now says the previous contracts were kept and names what changed.

The no-prior-registry branch is narrowed for the same reason. It claimed
nothing at all was written, and that is no longer true either: this path still
records the run against an existing bridge's metadata. The claim is now scoped
to contracts.json, which is the file it can actually speak for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): stop the total-failure log promising a preservation that did not happen

The warning fired before the prior registry was read, so it could only ever
promise one of the two things that might be true — and it promised the wrong
one to every group that has never synced: "keeping the contracts from the
previous sync" about a file that does not exist. The console line for that same
run, driven by `registryOutcome`, said the opposite.

It now lives inside the branch, after the read, with one message per outcome
chosen at the point the outcome is decided. The log and the console cannot
disagree, because the same fact selects both.

Both messages keep the warn level and the two repo lists.

Mutation-verified: reverting the split turns the no-prior-registry case red
while the preserved case — whose claim was already true — stays green. The
dry-run test's log filter was also widened to the sentence both messages share,
or the new wording would have made that assertion match nothing and pass
regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): make the bridge-failure warning describe what the code guarantees

The warning after a failed `writeBridge` promised that cross-repo impact would
report `truncated` until a sync succeeded. Nothing on that path produces that
signal.

The swap is the last step: `writeBridge` builds the new database in a staging
directory and only then moves the old one aside. A failure during the build
therefore leaves the previous sync's `bridge.lbug` exactly where it was, beside
the `meta.json` stamped for it — a pair that passes `bridgeMetaMatchesFile`
with the previous run's `unreadableRepos`. The next cross-repo query answers
`truncated: false` from superseded contracts, which is the opposite of what the
operator was told to expect, and worse than being told nothing.

The warning now says what is actually true: contracts.json is intact and
canonical, the bridge was not replaced, cross-repo queries may still answer from
the previous sync's contracts, and nothing marks them as superseded.

The metadata is deliberately NOT re-stamped to make the original promise true.
That would recreate exactly the metadata/database mis-pairing the stamping on
the preserve path exists to prevent, and the comment at the warning records it.

The claim is asserted against captured log output rather than left to the state
tests. Those check which pairs match and what the preserve path writes; every
one of them stays green while this sentence reverts to promising a truncation.
An unasserted user-facing branch is the defect class this change is closing, so
it does not get to close it while remaining one.

No filesystem shape makes the real `writeBridge` fail while
`writeContractRegistry` succeeds — they write into the same directory one line
apart — so the failure is armed through a pass-through wrapper on the file's
existing mock. It delegates byte-for-byte unless a test arms it, and is reset
around the new suite.

Mutation-verified: restoring the original wording turns its own assertion red
and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mcp): name every registry outcome group_sync can actually return

The tool's description told agents `registryOutcome` is 'written' or
'preserved'. It has a third reachable value: 'no-prior-registry', returned when
nothing could be read AND there was no previous contracts.json to carry
forward. An agent calling this tool against a group that has never synced got a
value its own tool description said did not exist, and no way to tell it apart
from the case where the previous contracts survive.

The distinction is the whole point of the value. After 'preserved' there is a
registry to read — stale, but real. After 'no-prior-registry' there is nothing
on disk at all, so a following group_contracts or group_impact has no registry
rather than an old one. Those need different responses from the caller.

'not-attempted' stays undocumented because it is unreachable through this tool,
and a guard asserts it stays that way.

The code comment above the annotations claimed the preserve path does NOT write
contracts.json. It does — it rewrites the file, keeping the previous contracts
and cross-links and refreshing only the two diagnostic lists, which the CLI's
own summary was corrected to say a few commits ago. Left alone it would have
re-seeded the same wrong claim next to the text that now states it correctly.

Mutation-verified: deleting the 'no-prior-registry' sentence turns the guard red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mcp): explain structural incompleteness on the impact tool and status resource

The impact tool's GROUP MODE paragraph described one cause of truncation — the
fan-out running out of room — and left an agent to assume that was the only one.
So a `truncated: true` carrying `truncationReason: 'incomplete-sync'` read as
"retry with a smaller scope", when retrying returns the identical floor forever:
the repos are absent from the bridge itself, and only a re-sync puts them back.
The old text also said the response carries the truncation fields "when it stops
early", which is wrong for that case — `truncatedRepos` names repos even when
ZERO crossings to them were attempted, because their contracts were never in the
bridge to cross to.

The paragraph now branches on the reason and gives each its remedy: 'timeout'
and 'partial' are runtime limits where a retry or a larger budget can help;
'incomplete-sync' is structural and the remedy is `group_sync`.

The reason union is now derived from an exported `as const` array rather than
written as a bare type. A type-only union gives a guard nothing to enumerate, so
the guard has to hand-list the members — and then it passes forever the moment a
fourth is added, which is the exact regression it exists to catch. The guard
iterates the runtime array instead. Verified by appending a probe member and
watching it go red, then removing it. The resolved type is unchanged; every
importer uses `import type` and none needed an edit.

The status resource said "Group index / contract staleness" and nothing about
the distinctions its payload now carries. It explains all of them: a repo absent
from the registry versus one whose entry could not be resolved, and the
`unreadableRepos` tri-state where an ABSENT key is not an empty one — absent
means the last sync never recorded what it could read, so cross-repo answers for
that group are a floor.

The description an MCP client actually receives lives in `getResourceTemplates`,
not in the context resource's inventory line the plan pointed at. Both now carry
the vocabulary, so the two surfaces cannot disagree about the same payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(group): serialize group syncs behind a fail-closed per-group lock

Two concurrent syncs of one group could lose one another's writes. Both read
the prior registry, both built contracts, both wrote — last writer won, and the
loser's work was gone with nothing reporting it. A group sync is long and
expensive and is exactly the operation whose lost update destroys contracts.

`syncGroup` now takes a lock for the whole persist section, acquired exactly
once. `acquireIndexLock` is not reentrant, so a second acquisition anywhere
below would deadlock the happy path rather than an edge case; `withGroupSyncLock`
has one call site and nothing inside it re-acquires.

The lock lives on a dedicated `sync-lock` directory inside the group directory,
mirroring the registry lock's dedicated directory rather than reusing the
resource's own — a lock directory that could collide with a per-repo index slot
repeats a bug the registry lock's comment already warns about.

It fails CLOSED, which is the opposite of `withRegistryLock` and deliberately
so. That one degrades to unlocked because it guards a sub-second JSON merge on
a latency-critical path; here running unprotected is the outcome the lock
exists to prevent. Three exits are covered: a timeout, an unwritable lock
directory, and the lock-free degradation the primitive performs silently.

That third exit needed a change in `index-lock.ts`, and it is the one declared
exception to keeping this work inside core/group/. `acquireIndexLock` answers a
read-only or permission-denied filesystem with a no-op handle that is
byte-identical in shape to a real one, so a caller for whom lock-free is not an
acceptable outcome could not tell the difference. It now carries an optional
`lockFree` marker. The change is additive by construction: no signature moves,
no control flow changes, nothing about when or how a lock is taken changes, and
every caller that ignores the field behaves exactly as before.

A filesystem probe inside the group module was considered and rejected on
evidence: `selectBackend` returns `socket` on Linux and Windows, where
`acquireViaSocket` never touches the filesystem and this branch cannot occur —
so a probe would refuse syncs on the two platforms that never degrade while
missing the one that does.

The timeout ceiling is a named 600s constant passed explicitly. The magnitude
matches the primitive's own analyze-sized default because a group sync is
analyze-shaped and a legitimately queued second sync must be able to wait out a
full first one. Passing it explicitly is about the override, not the magnitude:
`resolveTimeoutMs` resolves `GITNEXUS_INDEX_LOCK_TIMEOUT_MS <= 0` to Infinity,
which would turn fail-closed into a hang.

Cross-process exclusion is proved with a real spawned holder, not an in-process
mock, which cannot demonstrate the property this exists for. The lock-free
scenario pins `GITNEXUS_INDEX_LOCK_BACKEND=file` — unpinned it would pass on
two of three platforms while measuring nothing — and produces the failure by
injecting EACCES on one syscall rather than by chmod, so it runs identically on
Windows instead of being skipped there.

The CLI reports the failure through pino rather than a bare stderr write, which
this package lints as an error to keep that migration moving, and the test reads
the `msg` field rather than a raw substring — matching on the raw text would
have passed only by accident of quoting and would go green again if the line
were downgraded.

Nothing is skipped on any platform, and the test is registered for the
cross-platform shards.

Mutation-verified: removing the lock acquisition turns 6 scenarios red;
removing the lock-free rejection turns the degradation scenario red on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): make the sync-lock timeout name a cause it can establish

The fail-closed lock surfaced the primitive's own timeout message to users for
the first time, and that message says the wait was on "another gitnexus
analyze" — a cause its detection path cannot establish. It is the same
confident-about-what-it-could-not-determine claim this PR exists to remove,
inherited rather than written.

The wrapper now throws its own. It names the group, the lock directory, the
operation, and the elapsed wait, and it says plainly that nothing was written.

The holder clause branches on `holderKnown`. The socket backend exposes no owner
metadata and reports a placeholder pid of -1, so on that backend — and on the
file backend's malformed or vanished-lock timeouts — the message says the lock
stayed held but the backend cannot identify who held it, rather than printing a
pid that means nothing.

The elapsed wait is measured by the wrapper. `IndexLockTimeoutError` carries
only `holder` and `holderKnown`; the figure exists solely inside the string
being replaced, so it had to be taken rather than read.

One pre-existing assertion changed with it: the timeout case asserted
`'Timed out after 600000ms'` from the inherited text, which is precisely the
message this replaces.

Mutation-verified: restoring the inherited message turns the three assertion
cases red and leaves the control — a real acquisition that succeeds — green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): stop a losing sync from downgrading the one that beat it to the lock

Serializing is not ordering. Both syncs run extraction outside the critical
section, so a total-failure sync that acquires second reads the winner's fresh
registry as `prior` and rewrites it with all-unreadable lists. The lock alone
does not prevent that — it only decides who goes second, and the loser then
overwrites a healthy registry with a description of its own failure.
Deterministically, not as a rare interleave.

The guard is a compare-and-swap on the registry file's own identity: stat
before acquiring, re-stat after, and write nothing when they differ. Identity is
presence plus size, mtime and inode — `writeContractRegistry` publishes through
write-then-rename, so a real replacement always changes the inode even if size
and mtime happen to collide.

Deliberately NOT keyed on `generatedAt`, for two independent reasons. It is
stamped when the registry object is built, before the lock is acquired, so a
winner that waited would write a value older than the loser's start. And the
preserve path carries it forward verbatim by design — it dates the contracts,
not the write — so after any preserve sync it does not date the write at all,
leaving the comparison blind on exactly the pairing this guards. A file-identity
compare also needs no cross-process clock agreement.

The skip reports the existing `preserved` outcome. Nothing was written and a
prior registry was kept, which is what that value already means; a new one would
falsify the guard asserting the sync tool's description names every reachable
outcome, and would fall through the CLI's outcome chain, which has no fallback.

The bridge metadata refresh is skipped too, which the plan did not specify.
`refreshPreservedBridgeMeta` stamps THIS run's repo lists into meta.json, and
meta.json is where cross-repo impact reads completeness — so writing it would
report as unaccounted-for exactly the repos the winning sync had just accounted
for. That is the same downgrade being refused, one file over. Skipping both is
what makes `preserved` an honest answer here.

Mutation-verified: removing the after-stat and the skip turns the three decisive
cases red while both non-misfire controls stay green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(group): run the bridge swap inside the caller's critical section

The bridge swap needed the group lock, and could not take it: `syncGroup`
already holds it when it calls `writeBridge`, and `acquireIndexLock` is not
reentrant. Acquiring inside the swap would deadlock every sync on the happy
path rather than on an edge case.

So the body splits the way this repo already splits this shape — a lock-free
`writeBridgeUnlocked` whose precondition is that the caller holds the lock, and
a thin `writeBridge` wrapper that acquires it for direct callers, mirroring
`registerRepoUnlocked` / `withRegistryLock`. `syncGroup` calls the inner one;
everything else keeps calling `writeBridge` and is now serialized by it.

`writeBridge`'s exported signature is byte-identical to before, so no caller
changed and nothing about the exported surface moved.

The precondition is enforced by a comment naming the single production call
site, which is what the existing precedent does. A type could carry it, but the
repo's own answer to this question is a comment, and diverging here would make
this the odd one out for no additional guarantee.

`refreshPreservedBridgeMeta` is deliberately left unsplit. Its one caller is
already inside the critical section and it has no test callers, so an acquiring
wrapper would be dead code standing in for a guarantee the caller already
provides — and moving the lock inside it would be the second acquisition this
change exists to avoid.

Scope: this delivers writer-writer exclusion only. The reader-side promotion of
a leftover `.bak` into place runs on ordinary reads, outside any lock, and is
not claimed here — the pairing check remains the reader's defense. Confirmed as
live behavior while writing the crash-recovery test, which asserts on file
state rather than through `bridgeExists` for exactly that reason.

One test file beyond the two the unit named had to change: a suite mocks
`bridge-db` to inject a `writeBridge` failure and exercise the bridge-write
warning. Once the sync calls `writeBridgeUnlocked`, that fault was being
injected into a function the path no longer calls, and the test went red. The
mock is repointed.

Mutation-verified three ways. Pointing the sync back at the acquiring wrapper
deadlocks a single UNCONTENDED sync — the evidence that the nesting defect is
real and that this split is what prevents it. Removing the wrapper's
acquisition turns the direct-write exclusion case red. Making the lock-free half
acquire for itself turns the held-lock case red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(hygiene): reach every tracked text file with the raw-byte guard

The guard claimed to protect tracked source from a raw NUL — the byte that
makes git classify a file binary and costs it its diff, its inline comments and
its three-way merge on GitHub. It matched on an end-anchored extension regex
covering the JavaScript family, so most of what this repo tracks was never
looked at: JSON, YAML, TOML, Markdown, snapshots, SQL, protobuf, the .NET
project files, the shell and batch scripts.

Worse, an extension regex cannot reach a file that has none. `Dockerfile`,
`CODEOWNERS`, `LICENSE`, the husky hook and every bare dotfile were unreachable
by construction — no amount of widening the pattern would have covered them —
so a second basename filter had to exist for the claim to be true.

It stays an allowlist rather than becoming "everything git tracks", because the
repo legitimately tracks binaries whose extensions must stay out.

The two filters together now collect every one of the 5000 tracked files except
31 — the 30 native prebuilds and one PNG — and those 31 are exactly the files
that carry a NUL. The allowlist no longer has a gap that is not a genuine
binary.

The planted-fixture cases route through the collector's own predicate rather
than straight into the scanner. The pre-existing fixture test bypassed the
filter entirely, so it could only ever prove the byte locator worked, never
that the collector would hand it the file — which is precisely how the gap
survived.

Mutation-verified both ways: removing the basename filter drops `.gitignore`
and `Dockerfile` from the planted results, and reverting the extension regex
drops `.json` and `.md`.

One added case is a preservation pin rather than proof — that tracked binary
formats stay out passes either way, and guards the allowlist from becoming a
denylist later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(hygiene): stop the byte guard reading the vendored grammar tree

Widening the guard to every tracked text format also pulled in the vendored
tree-sitter grammars, and those are where the bytes are: four generated
`parser.c` files come to 62 MB between them, Kotlin's alone 33.7 MB. Excluding
that root drops 76 files but 66% of the bytes the scan reads — 97 MB down to
33 MB.

The exclusion is a single anchored prefix, matched case-sensitively with
`startsWith`, and both halves of that matter. A `vendor` path-SEGMENT match
would also drop first-party fixtures this repo tracks under directories named
`vendor` and `Vendor` — a Kotlin one, a PHP one, and three files under
gitnexus-web — silently narrowing coverage while the assertion pinned the loss
in place. Case-insensitivity would do the same to a `Vendor` directory at the
excluded root's own level.

The root is named in the guard itself, so the claim that it covers every
tracked text file stays honest about the one place it deliberately does not
look.

The cost comment was wrong and is now measured rather than estimated. It said
"the scan is ~10 ms" — ambiguous between locating the byte and reading the
files, and stale in its byte basis. Locating is ~14 ms; the reads dominate it
by two orders of magnitude, which is the actual reason for the concurrency pool
and the actual reason this exclusion is worth having. Every figure was
re-derived from the finished file rather than carried over from a draft.

The header's claim that `git ls-files` "never descends into vendor" was already
false — vendored code is tracked, so all 106 of its files were being reported
and read. Corrected here, where the distinction becomes load-bearing.

Registered in the cross-platform list first and given a shard weight second.
The weight table is only consulted for files already in that list, so a weight
entry alone is inert and the shard test filters unregistered keys without
complaining. The three-way split stays within 1.01x of ideal.

Mutation-verified three ways: a case-insensitive segment match, a
case-sensitive segment match, and a case-insensitive anchored prefix each turn
an assertion red.

The casing half was initially unfalsifiable — nothing tracked is named
`gitnexus/Vendor/`, so a tracked-set assertion could not distinguish it. Rather
than leave the claim unpinned or invent a fixture, it is pinned on the
predicate with a synthetic path; the tracked-set assertions pin the anchoring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(group): make the strict-read test able to see which read ran

The file bound both registry exports to one mock:

    readRegistry:       (...args) => readRegistryMock(...args),
    readRegistryStrict: (...args) => readRegistryMock(...args),

so the case named for the strict read asserted a behavior it could not
attribute. Point the production call at the lenient export and every assertion
still holds, because the mock answers the same way whichever one is called.

That is not a hypothetical. With this file as it was, and `syncGroup` mutated to
call `readRegistry` instead of `readRegistryStrict`, all 32 tests passed — the
suite was blind to the exact substitution it exists to prevent, and the fix it
guards could have been reverted without a single red.

The exports now have separate mocks: the lenient one always resolves an empty
list, which is its real contract, and only the strict one is armed by the cases
that need a failure. The named case also asserts directly that the strict read
was called and the lenient one was not, so the attribution is explicit rather
than implied by an outcome.

No tests added — the unit is about what the existing ones can see.

Mutation-verified: the same substitution now turns 24 cases red, including the
named one, and everything stays green unmutated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(group): pin the CLI output branches this PR introduced

The three sync outcomes and the status table's new labels had no assertions.
Every one of them is a sentence about what happened on disk, and this PR
corrected several that were false — a preserve branch that announced it had not
written the file it rewrites, a status table that called an unreadable registry
a missing entry. Text that describes state, with nothing pinning it, is how
those got wrong in the first place.

Six cases drive the real CLI end to end, through the two shapes that need no
indexed repo: members absent from the registry, and members registered at a
storage path with no index file, which makes every repo unreadable. The file
header claimed no LadybugDB-backed command was driven end to end; that is no
longer true and it now says so.

Each branch was suppressed in turn and its assertion goes red — all five that
the plan named.

One of those mutations first reported PASS, and the cause is worth recording: the
string being suppressed also appears inside a neighbouring branch's comment, so
the harness silenced the wrong line. That is a bad mutation, not a weak test.
The harness now asserts the marker it suppresses is unique before trusting the
result, and the redone check goes red.

The plan's sixth scenario is already covered by an existing case that asserts
both labels in one table, so it is not duplicated. A seventh case was added
beyond the plan: without a populated-list case, "prints neither line" would pass
just as well against a CLI that never printed that line at all.

Adds about 15s of measured spawn time locally; CI runs these against the built
dist, which is materially faster per spawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(group): assert the MCP payloads by exact shape, not by partial match

Nothing asserted what the group tools actually return. The sync response's
unreadable list and registry outcome, and the contract listing's incompleteness
fields, are documented in the tool descriptions an agent reads — and could have
been dropped in a refactor without a single test noticing.

The assertions are exact-shape rather than partial. A `toMatchObject` would let
a dropped key pass, which is precisely the regression these exist to catch: the
failure mode is an absent field, and a partial match is defined not to see one.
Absences are additionally asserted explicitly.

The tri-state has to survive the response boundary, and it is the reason exact
shape matters here more than usual. An absent `unreadableRepos` means the sync
never recorded what it could read, so the listing is a floor; an empty list
means it measured none; a populated list names them. Collapsing absent into
empty turns "we do not know" into "we checked, it is fine" — so a mutation that
replaces the conditional spread with `?? []` is covered specifically, not just
the outright deletion.

Mutation-verified per field: removing either sync forwarding line, deleting the
conditional spread, replacing it with the invent-empty form, dropping the
truncation triple, or hardcoding the provenance flag each turns an assertion
red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(group): stop the bridge input narrowing what unreadableRepos means

The same field had three definitions. The registry and the bridge metadata both
say it covers a repo this sync could not extract from — an index that would not
open, or an extractor that threw partway through, one bucket because the
consequence is one thing. The bridge input said only "whose index could not be
opened", which describes one cause and silently excludes the other.

It now points at the registry's definition instead of restating it a third
time. A definition written once and referenced cannot drift; three copies of it
already had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(group): record what the mtime pairing does and does not prove

The write-order fallback is a heuristic standing in for provenance, and a
future reader deciding whether to lean on it needs to know where it breaks
before they do. Both directions are now stated where the function is read
rather than only in the plan that introduced it.

The false-accept direction is a non-monotonic wall clock — mtime is realtime,
so an NTP step back, a snapshot restore, or container skew between the two
writes can leave a mis-paired set reading as ordered. Coarse filesystem
granularity is explicitly called out as NOT being that hazard, because it looks
like it: it collapses a pair written together to equal times, and equal is
accepted, which is the right answer for that pair.

The false-reject direction is any copy or restore that rewrites the database's
mtime after the metadata's. An intact legacy pair is demoted to a lower bound
and stays there until a sync re-stamps it, because nothing on the read path can
tell it apart from the swap window it imitates.

That second direction corrects a claim made while planning this work: that the
rule could only ever demote pairs already broken. It cannot. `cp -r` and
`rsync` without timestamp preservation both produce it on a healthy group, and
saying otherwise where the code is read would leave a future reader to discover
it the hard way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(storage): stop a corrupt registry quoting its own bytes into errors

`JSON.parse`'s SyntaxError embeds a window of the source around the failure —
V8 gives exactly ten characters either side — and the strict read rethrew it
untouched. The registry persists HTTPS remote URLs with their userinfo, so a
file that breaks next to one puts the credential into the error:

    Unexpected token 'L', ..."end.git"},LEAKCAN4RY"... is not valid JSON

The parse now has its own guarded region and reports the path and the failure
class, matching the two corrupt-registry errors already in this function.

The original error is discarded — not logged, not attached as `cause`. This
codebase's convention elsewhere is to hand the logger the Error so it captures
stack and cause, and following that convention here is precisely what would put
the byte window into the log. Under MCP stdio that log is written to the
client's log file on disk, so the thrown-error channel was never the only one
that mattered. The `catch` takes no binding, so the error cannot be reused by
accident later.

That was not theoretical: a sibling commit routes this message into
`unresolvableReason`, which `group status` returns to MCP clients and prints in
the CLI table. Every channel was traced — throw, cause, inspect with the full
chain, the logger, and both downstream consumers.

The leaking shape is narrower than it first appears, and worth recording. The
windowed message only fires when the parser fails at a value-start or trailing
position; a break inside a quoted string yields an unterminated-string error
carrying no window. So a plain mid-URL truncation does not leak — a short write
landing over a longer one does, leaving a URL fragment where a value was
expected. That is a reachable shape for the one machine-wide file every
gitnexus process writes.

The test asserts the message still names the path and the corruption class, not
only that the secret is absent. Asserting absence alone would stay green if the
message became empty.

Mutation-verified: restoring the raw rethrow brings the token back verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(storage): drop the stale lenient call-site count

The docstring said keeping `readRegistry`'s signature untouched leaves "its
nine other call sites" unaffected. There were thirteen when the discrepancy was
noticed and fourteen by the time it was fixed. The same figure appeared in the
test file's header.

Replaced rather than corrected. A count in prose next to code that moves is a
claim that goes stale without anything failing — which is the defect class this
change set exists to remove, so re-seeding a fresh number would be repeating it
with a longer fuse. The argument was never about the quantity: leaving the
signature alone keeps every lenient caller provably unaffected whether there is
one or fifty.

Also withdrawn while here: the claim that the bridge schema-version guards
diverge between call sites. They do not — the two forms are complements for
every value a writer can produce, there are three sites rather than the two
claimed, and all three agree. Recording a divergence that does not exist would
leave a future reader chasing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(group): add an auditable finding-to-commit map

The Definition of Done claims every review finding has exactly one commit and
that reverting it reintroduces that finding and no other. Without a map that
claim is only checkable by whoever holds the review report, which is one person
for a short time.

The map lists all 28 primary findings against their commits, the three findings
whose suggested fix was deliberately not implemented and what shipped instead,
and the four defects found while executing that no reviewer raised.

It also records the revert contract honestly. Revertability is
dependency-aware, not absolute: the shared completeness helper has three
consumers, so reverting it alone does not build. That coupled set is named
rather than left for someone to discover mid-revert.

Two sections exist because the work produced them, not because the plan asked.
Six claims in the plan turned out to be contradicted by the code — among them a
scope predicate that would have reintroduced the bug its unit was closing, and
an assertion about the mtime rule that was simply wrong. Recording only the
findings would leave the impression the plan was followed as written. Five
residual risks are listed for the same reason, including that R14 is not met on
this PR: the diff attribute works locally but GitHub reads it from the base
side, so this PR's own sync.ts stays binary in the web view and every PR after
it renders as text.

Not under docs/ — that path is gitignored, so a map written there would never
reach the PR and the audit it exists for could not be performed by anyone else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): read a version that is not a version as no provenance

Raised by the check bot on this PR, and real — the bot found one symptom of it;
the field splits four gates apart, not one.

`readBridgeMeta` accepted any numeric `version`, and `0` is this file's word for
"no provenance". A parseable but impossible value — negative, fractional — is
not a schema version, and each gate that reads the field disagreed about it:

  ensureBridgeReady      `> 0 && !== CURRENT`  → opens the bridge
  openBridgeDbReadOnly   `> 0 && !== CURRENT`  → opens the bridge
  bridgeExists           `=== 0 || === CURRENT` → says it is not there
  bridgeProvenanceUnknown `=== 0`               → reports the answer complete

Four verdicts about one file, and the last one is a fail-open of exactly the
class this PR exists to close: a bridge nothing can vouch for, reported as
fully accounted for.

The suggested fix was to widen the provenance check to `<= 0`. That closes the
reported symptom and leaves `bridgeExists` still disagreeing with both openers,
so it is fixed at the reader instead: a version that is not a positive integer
normalizes to the sentinel the gates were all written against. One change, four
gates agreeing by construction, rather than teaching each of them the same new
case and hoping the fifth reader remembers.

Infinity is covered too, though by the pre-existing type check rather than the
range one — JSON cannot carry it, so it arrives as `null`. Recorded at the test
so the case is not mistaken for proof of the range check.

Mutation-verified: restoring the loose numeric check turns the negative and
fractional cases red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): stop a malformed contracts.json reading as an unresolvable registry entry

Raised by the check bot on this PR. Its stated mechanism was wrong — `loadMeta`
returns null on every error and `checkStaleness` catches everything, so neither
can throw — but its conclusion was right, and there is a concrete path it did
not name.

`readContractRegistry` is a bare `JSON.parse(content) as ContractRegistry` with
no shape check, and the snapshot lookup guarded only the registry object:

    registry?.repoSnapshots[repoPath]

The `?.` covers `registry` being null, not `repoSnapshots` being absent. A
contracts.json without that field — a legacy file, a hand-edit, a truncated
write — throws `TypeError: Cannot read properties of undefined`, which lands in
the catch that labels failures as unresolvable GLOBAL-registry entries. So a
group whose own contracts file is malformed reported every repo as a broken
registry row, sending the operator to repair a file that was fine.

An error from one cause presented as another, which is the defect this PR has
been removing everywhere else.

The optional chain closes the crash. The try is also narrowed to the call that
earns the label: only `resolveRepo` sits inside it now, so "did not resolve"
describes something that actually failed to resolve rather than whatever else
happened to throw nearby. The comment records why the other two calls in that
block cannot throw, so the next reader does not have to re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(group): give the completeness fold a module no native binding reaches

The shared fold ended up in `cross-impact.ts`, which statically imports
`bridge-db.ts` and through it the native LadybugDB binding. `groupContracts`
therefore reached it through `await import('./cross-impact.js')` — loading that
whole module graph to run a Set union and a ternary. Measured: 44-51ms and
8.4MB of RSS on first call, paid once per MCP server and once per
`gitnexus group contracts` invocation.

`completeness.ts` holds the vocabulary and the fold and imports nothing but
types. `service.ts` imports it statically; the lazy import and the comment
justifying it both go. `cross-impact.ts` re-exports so the three surfaces still
have one import site for the vocabulary.

Three other duplications collapse into the same move.

`traceCompleteness` was hand-writing `{truncated, truncationReason,
riskEpistemic}` — a third writer of the pair `truncationFields` exists to keep
mechanically linked (#2787), in the file the consolidation had just touched. It
calls the helper now.

`recordedRepoList` existed twice, byte-identical, one copy's docblock saying it
mirrored the other. That gate is the predicate the whole
absent-vs-empty-vs-populated distinction rests on, applied to the same two
lists on both the registry and the bridge — tightening one copy would have
fixed one surface silently. One definition now.

The trace's scope predicate compared repo paths with `===` while its sibling in
`cross-impact.ts`, added in the same change, went through `repoInSubgroup` with
a comment about not growing a second notion of membership. It had grown one:
the helper normalizes separators and strips trailing slashes, so the same
group.yaml spelling could be in scope for impact and out of scope for trace.

Also here: `registryIdentifies` was a third, weaker copy of the registry's path
rule — it skipped `realpath`, so a symlinked row would not match where the real
resolver would. It uses `canonicalizePath`/`registryPathEquals` now.
`contracts.json` is no longer respelled as a literal in `sync.ts`; `storage.ts`
owns the name it reads and writes. And the runtime-truncation predicate is
bound once instead of written out at both the flag and the reason, where
forgetting the second would label a retry-able answer `incomplete-sync`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(group): give the lost-the-race sync its own outcome instead of overloading preserved

A sync that finds contracts.json replaced while it waited for the lock reported
`registryOutcome: 'preserved'`. That value already meant something else, and the
two differ in exactly the thing the value is for: `preserved` rewrites the file
with this run's diagnostics; this path does not touch it and deliberately does
not record them.

So both surfaces stated something false about disk. The tool description told
agents `preserved` means "contracts.json was rewritten ... refreshing only
missingRepos/unreadableRepos to describe THIS run (the file changed)". The CLI
said "only the unreadable/missing repo lists were refreshed to describe THIS
run". On the lost-race branch nothing was written and the log line beside it
says so outright.

That is the defect class this whole change set removes, reintroduced by the
change set itself — and the reasoning recorded at the time makes it worse, not
better: a new value was rejected because it "would fall through cli/group.ts's
outcome chain, which has no fallback branch". A renderer limitation decided a
domain value, and the description then had to cover two states with one
sentence that fits one of them.

`superseded` is its own outcome now, described in its own words to agents and
rendered in its own words at the CLI. The registry on disk is FRESHER than this
response's diagnostics, which is the opposite of every other non-written
outcome and is why an agent needs to tell them apart.

The CLI renders from a `Record` keyed on the union, so the next outcome fails
the build here rather than printing nothing — the gap that made folding the
state in look like the cheap option.

The description guard is scoped per clause rather than over the whole string.
It forbade "untouched" anywhere, which was right when one clause could only lie
in that direction and wrong now that another clause is accurately untouched. It
also asserts the superseded clause says so, or the two collapse back into one
word for two states.

Found by the quality pass over this branch, not by review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(group): read bytes and stat through one handle, not two path lookups

CodeQL flagged both sites as `js/file-system-race`, high severity, and it is
right about the shape. `stat(path)` followed by `readFile(path)` is two
independent path resolutions with a window between them — the classic
check-then-use race.

It also made the assertions weaker than they read. These two tests exist to
prove a specific file was left untouched, and two lookups can land on different
inodes, so "the bytes and the mtime are both unchanged" was not actually a
statement about one file. The distinction is the whole point here rather than a
technicality.

`snapshotFile` opens the path once and takes both answers from that handle. The
race is gone because there is no second lookup, and the assertion now genuinely
concerns one inode.

I had previously triaged these as below the ruleset's threshold and left them
for the repository owner. That was wrong: they carry
`security_severity_level: high`, and the branch ruleset gates on
`high_or_higher`, so they were blocking the merge rather than sitting under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-08-26 09:37:16 +01:00
.agents/plugins feat: full Codex support — hooks, plugin marketplace, and setup (#2328, supersedes #1131) (#2369) 2026-07-04 13:32:17 +01:00
.claude docs(claude-skills): bind repository and worktree identity in multi repo skills (#2981) 2026-08-18 14:09:09 +00:00
.claude-plugin chore: release v1.6.9 (#2367) 2026-07-04 07:53:06 +01:00
.cursor fix(storage): rename index metadata to gitnexus.json with dual-write compatibility (#2363) 2026-07-03 19:32:59 +01:00
.devcontainer fix: large-repo analyze OOM and false worker-timeout cascade (#2649) (#2679) 2026-07-25 09:16:17 +01:00
.gemini/commands feat(review): add PR reviewer swarm agents (#1851) 2026-05-29 18:24:16 +01:00
.github chore(deps): bump actions/attest-build-provenance from 4.1.1 to 4.2.2 (#3005) 2026-08-24 09:46:14 +01:00
.history/gitnexus fix(test): add --repo to CLI e2e tool tests for multi-repo environment 2026-03-18 08:12:25 +00:00
.husky feat: configure prettier with pre-commit hook (#563) 2026-03-28 14:58:04 +00:00
.sisyphus/drafts fixed constructor to method relation not getting stored in kuzu issue 2026-01-26 22:58:15 +05:30
deploy/kubernetes ci(docker): mirror signed images to Docker Hub alongside GHCR (#1029) 2026-04-23 18:59:26 +01:00
docs/plans fix(python): resolve calls through an unaliased dotted namespace import (#2826) (#2828) 2026-08-05 11:35:27 +01:00
Documentation Add Kilo Code + GitNexus MCP setup guide (#2259) 2026-07-02 11:04:22 +01:00
eslint-rules fix(windows): 32767-char tree-sitter crash + VECTOR extension SIGSEGV (#1433) 2026-05-10 16:00:36 +01:00
eval chore(deps): bump aiohttp in /eval in the uv group across 1 directory (#2825) 2026-08-04 09:55:59 +00:00
gitnexus fix(group): stop reporting what could not be measured as a measurement of zero (#3012) 2026-08-26 09:37:16 +01:00
gitnexus-claude-plugin docs(claude-skills): bind repository and worktree identity in multi repo skills (#2981) 2026-08-18 14:09:09 +00:00
gitnexus-cursor-integration docs(claude-skills): bind repository and worktree identity in multi repo skills (#2981) 2026-08-18 14:09:09 +00:00
gitnexus-shared fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912) (#2939) 2026-08-13 14:50:53 +01:00
gitnexus-test-setup feat: merge gitnexus-mcp into gitnexus package - unified CLI+MCP 2026-02-04 01:12:41 +05:30
gitnexus-web chore(deps)(deps): bump @langchain/anthropic in /gitnexus-web (#3004) 2026-08-24 12:17:45 +01:00
pr-swarm-review feat(skills): GitNexus Engineering Tool Kits (#2566) 2026-07-19 15:07:24 +01:00
.cursorrules docs: agent development framework, GitHub templates, eval refactor (#479) 2026-03-25 06:48:41 +00:00
.dockerignore fix(ci): Change docker base image from alpine to debian (#1014) 2026-04-21 21:31:58 +01:00
.env.example feat(analyze): private GitHub repos via PAT + Azure DevOps Server support (#2076, #2210) (#2223) 2026-06-16 05:49:02 +01:00
.git-blame-ignore-revs feat: configure eslint with unused import removal (#564) 2026-03-28 15:28:09 +00:00
.gitattributes fix(group): stop reporting what could not be measured as a measurement of zero (#3012) 2026-08-26 09:37:16 +01:00
.gitignore chore: stop tracking docs/plans (planning output stays local) 2026-07-21 10:09:35 +00:00
.gitleaks.toml feat(analyze): private GitHub repos via PAT + Azure DevOps Server support (#2076, #2210) (#2223) 2026-06-16 05:49:02 +01:00
.gitleaksignore chore(security): suppress deleted auth placeholder 2026-07-16 10:08:59 +07:00
.mcp.json fix: use cross-platform npx command in .mcp.json 2026-02-22 17:46:04 +00:00
.prettierignore chore(quality): exclude test/fixtures from CodeQL, ESLint, and Prettier (#1313) 2026-05-04 09:35:34 +01:00
.prettierrc feat: configure prettier with pre-commit hook (#563) 2026-03-28 14:58:04 +00:00
.windsurfrules resources implemented and agents.md and skills updated to use it 2026-02-05 05:13:48 +05:30
AGENTS.md fix(mcp): stop scaling the detect_changes query with the diff's hunk count (#2915) (#2930) 2026-08-12 14:51:17 +01:00
ARCHITECTURE.md feat(routes): support JS data route tables (#2972) 2026-08-18 04:39:45 +01:00
CHANGELOG.md perf(hooks): cmdline-first Linux db-lock scan, drop the lsof fallback (#2180) (#2183) 2026-06-13 11:52:14 +01:00
CLAUDE.md fix(mcp): stop scaling the detect_changes query with the diff's hunk count (#2915) (#2930) 2026-08-12 14:51:17 +01:00
compound-engineering.local.md feat: Phase 7 type resolution — return-aware loop inference & PHP class-property iterables (#341) 2026-03-18 08:39:38 +00:00
CONTRIBUTING.md feat(serve): validate and port-scope the origin/proxy configuration surface (#2820) 2026-08-05 06:52:39 +01:00
docker-compose.yaml feat(web): support GITNEXUS_BACKEND_URL env var for Docker deployments (#1286) 2026-05-25 11:21:11 +01:00
docker-server.mjs feat(render): add one-click deploy to render support (#2804) 2026-08-06 00:19:44 +00:00
docker-server.test.mjs feat(render): add one-click deploy to render support (#2804) 2026-08-06 00:19:44 +00:00
Dockerfile.cli feat(render): add one-click deploy to render support (#2804) 2026-08-06 00:19:44 +00:00
Dockerfile.web fix(security): Pin Docker Node base images, remove runtime package-manager CVE surface, verify Trivy on PRs, and harden Dependabot policy (#1455) 2026-05-09 16:55:31 +01:00
DoD.md feat(skills): GitNexus Engineering Tool Kits (#2566) 2026-07-19 15:07:24 +01:00
eslint.config.mjs fix(windows): 32767-char tree-sitter crash + VECTOR extension SIGSEGV (#1433) 2026-05-10 16:00:36 +01:00
GUARDRAILS.md feat: close reported graph blind spots in reference resolution, analyze and storage (#2856) 2026-08-08 09:58:14 +01:00
LICENSE docs: update license copyright holder 2026-02-03 22:54:01 +05:30
llms.txt docs: agent development framework, GitHub templates, eval refactor (#479) 2026-03-25 06:48:41 +00:00
MIGRATION.md feat: close reported graph blind spots in reference resolution, analyze and storage (#2856) 2026-08-08 09:58:14 +01:00
package-lock.json chore(deps): update brace-expansion and js-yaml versions in package-lock.json (#2952) 2026-08-13 07:28:25 +01:00
package.json feat(package): add gitnexus commands for analysis 2026-04-29 16:37:06 +03:00
README.md Change project title in README (#2986) 2026-08-18 01:55:30 -07:00
render.yaml feat(render): add one-click deploy to render support (#2804) 2026-08-06 00:19:44 +00:00
RUNBOOK.md feat: close reported graph blind spots in reference resolution, analyze and storage (#2856) 2026-08-08 09:58:14 +01:00
SECURITY.md feat(render): add one-click deploy to render support (#2804) 2026-08-06 00:19:44 +00:00
skills.mdm FEAT: Added support for optional skill generation based on KuzuDB after initial repo analysis (npx gitnexus analyze --skills) (#171) 2026-03-13 08:29:13 +00:00
swift-ingestion-gaps.md docs: add macro declarations to Swift ingestion gaps 2026-03-23 14:21:32 +01:00
TESTING.md refactor(ingestion): delete legacy call-resolution DAG + heritage processor (RING4-1, #942) (#2023) 2026-06-04 11:07:37 +01:00
type-resolution-roadmap.md feat: implement cross-file binding propagation for multiple languages 2026-03-21 07:47:04 +00:00
type-resolution-system.md feat(ingestion): TypeScript registry-primary scope resolution (Ring 3) (#1050) 2026-04-26 08:23:08 +01:00

GitNexus (Akon Labs)

⚠️ Important Notice: GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is not affiliated with, endorsed by, or created by this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.

abhigyanpatwari%2FGitNexus | Trendshift

Discord npm version License: PolyForm Noncommercial OpenSSF Scorecard CI Workflows

The nervous system for agent context.

Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow — then exposes it through smart MCP tools so AI agents never miss code.

💬 Discord · 🌐 Web UI · 🏢 Enterprise (SaaS & self-hosted)

https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72

Like DeepWiki, but deeper. DeepWiki helps you understand code. GitNexus lets you analyze it — a knowledge graph tracks every relationship, not just descriptions.

TL;DR: The CLI + MCP makes your AI agent reliable — it gives Cursor, Claude Code, Antigravity, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity. The Web UI is a quick way to chat with any repo in the browser.

Quick Start

# 1. Index your repo (run from repo root)
npx gitnexus analyze

# 2. Connect your editors (one-time, auto-detects Claude Code, Cursor, Codex, …)
npx gitnexus setup

That's it. analyze indexes the codebase, installs agent skills, registers Claude Code hooks, and creates AGENTS.md / CLAUDE.md context files — all in one command. setup writes the MCP config so your AI agent can use the graph.

Install problems? npm 11 crash · slow cold install · no C++ toolchain

On npm 11.x? npx can crash during install with Cannot destructure property 'package' of 'node.target' (an npm/arborist bug, before GitNexus runs). Use pnpm instead — it builds the native deps explicitly:

pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze

Or install globally (npm install -g gitnexus@latest) and run gitnexus analyze. See #1939.

Fastest MCP startup: install globally (npm i -g gitnexus) before running gitnexus setup — this writes an absolute-path MCP config that bypasses npx entirely. On a cold cache, an npx-based MCP install can exceed Claude Code's MCP_TIMEOUT default (~30s).

No C++ toolchain? Set GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 before npm install -g gitnexus to skip the vendored grammar materialize/build for tree-sitter-dart, tree-sitter-proto, tree-sitter-swift, and tree-sitter-kotlin — those four languages won't be parsed, but install completes in seconds without python3/make/g++. Strict =1 only — any other value falls through to the rebuild.

Behind an HTTP proxy / regional firewall? onnxruntime-node's postinstall downloads optional CUDA binaries from api.nuget.org and ignores HTTP_PROXY/HTTPS_PROXY (#2370). The embedding stack is an optional dependency, so a failed download no longer breaks the install — and it self-heals: the first gitnexus analyze --embeddings (or gitnexus embeddings install) fetches the stack through your npm registry config (mirrors/proxies apply, no NuGet) into ~/.gitnexus/embedding-runtime (override with GITNEXUS_EMBEDDING_RUNTIME_DIR). The on-demand prefix needs Node with module.registerHooks (≥ 22.15 on 22.x, ≥ 23.5 on 23.x); on older Node, keep the stack in the install itself with ONNXRUNTIME_NODE_INSTALL=skip npm install -g gitnexus (works on every supported Node).

About tree-sitter-kotlin: like Dart/Proto/Swift, Kotlin is a vendored grammar (under gitnexus/vendor/tree-sitter-kotlin). Upstream ships source only (no prebuilt binaries), so GitNexus cross-builds the platform prebuilds itself (via the build-tree-sitter-prebuilds GitHub Actions workflow) and vendors them — the same uniform pipeline used for Dart, Proto, and Swift. node-gyp-build selects the right .node at require time, so no C/C++ toolchain is needed. If no prebuild matches your platform-arch, only Kotlin (.kt/.kts) parsing is unavailable; the rest of gitnexus is unaffected.

Deploy to Render

Deploy GitNexus in one click:

Deploy to Render

The Blueprint creates two services. gitnexus-server runs gitnexus serve as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. gitnexus-web is the public one. It serves the UI and reverse-proxies /api/* to the server, so the browser talks to a single origin.

At the Blueprint's defaults this runs about $35/month: $25 for the server's standard instance, $7 for the web service's starter instance, and $2.50 for the 10 GB disk. See Render's pricing for other plans.

The deploy generates an access token, and the UI asks for it on first use:

  1. Open the gitnexus-web service in your Render dashboard.
  2. Copy GITNEXUS_SERVE_AUTH_TOKEN from its Environment tab.
  3. Load the site and paste the token into the prompt (or the settings panel).

Every /api/* request carries that token as a header, and the proxy answers 401 without it. The browser keeps it in sessionStorage, so a new tab asks again. To rotate it, edit the environment variable and redeploy.

The proxy strips Origin before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes Origin-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See SECURITY.md.

Indexing is memory-bound. If gitnexus-server runs out of memory on a large repo, raise its plan, which sets available RAM: standard is 2 GB, pro is 4 GB. Raise sizeGB only if the disk fills with clones and indexes.

Two Ways to Use GitNexus

CLI + MCP (recommended) Web UI
What Index repos locally, connect AI agents via MCP Visual graph explorer + AI chat in browser
For Daily development with Cursor, Claude Code, Antigravity, Codex, Windsurf, OpenCode Quick exploration, demos, one-off analysis
Scale Full repos, any size Limited by browser memory (~5k files), or unlimited via backend mode
Install npm install -g gitnexus No install — gitnexus.vercel.app
Storage LadybugDB native (fast, persistent) LadybugDB WASM (in-memory, per session)
Parsing Tree-sitter native bindings Tree-sitter WASM
Privacy Everything local, no network Everything in-browser, no server

Bridge mode: gitnexus serve connects the two — the web UI auto-detects the local server and can browse all your CLI-indexed repos without re-uploading or re-indexing.

Why a Knowledge Graph?

Tools like Cursor, Claude Code, Codex, Cline, Roo Code, and Windsurf are powerful — but they don't truly know your codebase structure. So this happens:

  1. AI edits UserService.validate()
  2. Doesn't know 47 functions depend on its return type
  3. Breaking changes ship

Traditional Graph RAG gives the LLM raw graph edges and hopes it explores enough. GitNexus precomputes structure at index time — clustering, tracing, scoring — so tools return complete context in one call:

flowchart TB
    subgraph Traditional["Traditional Graph RAG"]
        direction TB
        U1["User: What depends on UserService?"]
        U1 --> LLM1["LLM receives raw graph"]
        LLM1 --> Q1["Query 1: Find callers"]
        Q1 --> Q2["Query 2: What files?"]
        Q2 --> Q3["Query 3: Filter tests?"]
        Q3 --> Q4["Query 4: High-risk?"]
        Q4 --> OUT1["Answer after 4+ queries"]
    end

    subgraph GN["GitNexus Smart Tools"]
        direction TB
        U2["User: What depends on UserService?"]
        U2 --> TOOL["impact UserService upstream"]
        TOOL --> PRECOMP["Pre-structured response:
        8 callers, 3 clusters, all 90%+ confidence"]
        PRECOMP --> OUT2["Complete answer, 1 query"]
    end

Core innovation: Precomputed Relational Intelligence

  • Reliability — the LLM can't miss context; it's already in the tool response
  • Token efficiency — no 10-query chains to understand one function
  • Model democratization — smaller LLMs work because the tools do the heavy lifting

What Your AI Agent Gets

17 MCP tools (15 per-repo + 2 group)

Tool What It Does
list_repos Discover all indexed repositories (paginated — limit/offset)
query Process-grouped hybrid search (BM25 + semantic + RRF)
context 360-degree symbol view — categorized refs, process participation
impact Blast radius analysis with depth grouping and confidence
trace Shortest directed path between two symbols (call + class-member edges)
detect_changes Git-diff impact — maps changed lines to affected processes
check Read-only structural checks against the indexed graph
rename Multi-file coordinated rename with graph + text search
cypher Raw Cypher graph queries
route_map API route map — which components fetch which endpoints, and handlers
tool_map MCP/RPC tool definitions — where they're defined and handled
shape_check Validate API response shapes against consumers' property accesses
api_impact Pre-change impact report for an API route handler
explain Explain persisted taint findings (source→sink flows, --pdg indexes)
pdg_query Query control/data dependence at statement level (--pdg indexes)
group_list List configured repository groups
group_sync Rebuild a group's Contract Registry and cross-repo links

Per-repo tools take an optional repo parameter (omit it when only one repo is indexed) and an optional branch for indexes pinned with gitnexus analyze --branch. Omitting branch queries the workspace index, which follows your checked-out working tree — switching branches and re-running gitnexus analyze updates it incrementally. explain and pdg_query need an index built with gitnexus analyze --pdg.

Resources for instant context

Resource Purpose
gitnexus://repos List all indexed repositories (read this first)
gitnexus://setup Setup and usage guidance for agents
gitnexus://repo/{name}/context Codebase stats, staleness check, and available tools
gitnexus://repo/{name}/clusters All functional clusters with cohesion scores
gitnexus://repo/{name}/cluster/{name} Cluster members and details
gitnexus://repo/{name}/processes All execution flows
gitnexus://repo/{name}/process/{name} Full process trace with steps
gitnexus://repo/{name}/schema Graph schema for Cypher queries
gitnexus://group/{name}/contracts A group's extracted contracts and cross-links
gitnexus://group/{name}/status Staleness of repos in a group

2 MCP prompts for guided workflows

Prompt What It Does
detect_impact Pre-commit change analysis — scope, affected processes, risk level
generate_map Architecture documentation from the knowledge graph with mermaid diagrams

Agent skills installed to .claude/skills/ and .agents/skills/ (if .agents/ exists) automatically

  • Exploring — navigate unfamiliar code using the knowledge graph
  • Debugging — trace bugs through call chains
  • Impact Analysis — analyze blast radius before changes
  • Refactoring — plan safe refactors using dependency mapping
  • Guide — GitNexus tool/resource/schema reference for the agent
  • CLI — run analyze/status/clean/wiki commands on request
  • PDG Query — statement-level control/data dependence queries (--pdg index)
  • Taint Analysis — source→sink data-flow findings (--pdg index)
  • Plan (/gitnexus-plan) — implementation-ready engineering plans backed by the graph and PDG slices
  • Work (/gitnexus-work) — executes a plan as impact-checked, detect_changes-gated atomic commits
  • Review (/gitnexus-review) — graph-backed review of a PR, branch, range, or local diff, with taint pass and per-domain expert lenses
  • LFG (/gitnexus-lfg) — the full pipeline: plan → user gate → work → review

Repo-specific skills — run gitnexus analyze --skills and GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates each one as a direct project skill under .claude/skills/gitnexus-area-<name>/. Each skill describes a module's key files, entry points, execution flows, and cross-area connections, and is regenerated on each --skills run to stay current.

When a repo contains an .agents/ directory, the standard and generated skills are also mirrored to .agents/skills/ (e.g. .agents/skills/gitnexus-cli/, .agents/skills/gitnexus-area-<name>/) so agents that read repo-local .agents/skills/ (like Codex) stay in sync.

Editor Setup

gitnexus setup auto-detects your editors and writes the correct global MCP config. Run it once. To configure only selected integrations, pass --coding-agent/-c with a comma-separated list, e.g. gitnexus setup -c cursor,codex.

Editor MCP Skills Hooks (auto-augment) Support
Claude Code Yes Yes Yes (PreToolUse + PostToolUse) Full
Cursor Yes Yes Yes (postToolUse, manual install) Full
Antigravity (Google) Yes Yes Yes (AfterTool, Gemini CLI hooks schema)¹ Full
Codex Yes Yes Yes (PreToolUse + PostToolUse, Codex hooks) Full
OpenCode Yes Yes MCP + Skills
CodeBuddy (Tencent) Yes Yes MCP + Skills
Qoder (Alibaba) Yes Yes MCP + Skills
Windsurf Yes MCP

Claude Code and Codex get the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.

¹ Antigravity hooks follow the Gemini CLI hooks reference (Antigravity 2.0 is the documented successor to Gemini CLI). Augmentation runs in AfterTool because BeforeTool has no context-injection channel in the Gemini contract — the agent sees graph context appended to the tool result via hookSpecificOutput.additionalContext. Stale-index hints land in the same channel after a successful git commit/merge/rebase/cherry-pick/pull. The schema may evolve if Antigravity-specific hook docs diverge from Gemini CLI's; the implementation will track those changes.

Manual MCP configuration (if you prefer not to run gitnexus setup)

Claude Code (full support — MCP + skills + hooks):

# macOS / Linux
claude mcp add gitnexus -- npx -y gitnexus@latest mcp

# Windows
claude mcp add gitnexus -- cmd /c npx -y gitnexus@latest mcp

Codex (full support — MCP + skills + hooks):

codex mcp add gitnexus -- npx -y gitnexus@latest mcp

Or via ~/.codex/config.toml (system scope) / .codex/config.toml (project scope):

[mcp_servers.gitnexus]
command = "npx"
args = ["-y", "gitnexus@latest", "mcp"]

Codex hooks (PreToolUse graph enrichment + PostToolUse stale-index detection in ~/.codex/hooks.json, same schema as Claude Code) need the bundled adapter script, so they are installed by gitnexus setup -c codex rather than manually.

Alternatively, install everything as a Codex plugin (MCP + skills + hooks in one step):

codex plugin marketplace add abhigyanpatwari/GitNexus
# then inside Codex: /plugins → install "GitNexus"

Codex notes: SessionStart is intentionally not registered — Codex reads AGENTS.md natively, which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via /hooks before they run. Pick one install route (gitnexus setup -c codex or the plugin): plugin hooks load alongside ~/.codex/hooks.json, so installing both can fire duplicate hooks per tool call.

Cursor (~/.cursor/mcp.json — global, works for all projects):

{
  "mcpServers": {
    "gitnexus": {
      "command": "npx",
      "args": ["-y", "gitnexus@latest", "mcp"]
    }
  }
}

Antigravity (Google) — ~/.gemini/antigravity/mcp_config.json:

{
  "mcpServers": {
    "gitnexus": {
      "command": "npx",
      "args": ["-y", "gitnexus@latest", "mcp"]
    }
  }
}

gitnexus setup also merges an AfterTool entry into ~/.gemini/settings.json (under the canonical Gemini CLI hooks schema) and installs skills to ~/.gemini/antigravity/skills/. Existing user hooks are preserved. The hook adapter's path is rewritten at install time, so run gitnexus setup rather than hand-editing.

OpenCode (~/.config/opencode/config.json):

{
  "mcp": {
    "gitnexus": {
      "type": "local",
      "command": ["gitnexus", "mcp"]
    }
  }
}

CodeBuddy (Tencent) — priority chain, edit the first non-empty file that exists: ~/.codebuddy/.mcp.json (recommended) → ~/.codebuddy/mcp.json (deprecated) → ~/.codebuddy.json (legacy). CodeBuddy reads only the first existing file, so adding servers to a higher-priority file than the one currently in use would hide the servers below it. Create ~/.codebuddy/.mcp.json only if none exist:

{
  "mcpServers": {
    "gitnexus": {
      "command": "npx",
      "args": ["-y", "gitnexus@latest", "mcp"]
    }
  }
}

Qoder (Alibaba) — ~/.qoder.json:

{
  "mcpServers": {
    "gitnexus": {
      "command": "npx",
      "args": ["-y", "gitnexus@latest", "mcp"]
    }
  }
}
MCP read-only mode

Set GITNEXUS_MCP_READ_ONLY=1 before starting the MCP server to expose only the proven single-repository read surface. Raw cypher, rename and group tools, group routing, and group resources are omitted from discovery and rejected before backend dispatch. Tool descriptions and generated setup/context resources are scrubbed so they do not recommend unavailable routes.

The default is unchanged when the variable is unset or 0. Any other value fails server startup rather than silently weakening the policy.

MCP repository policy

Set GITNEXUS_MCP_ALLOWED_REPOS to a comma-separated list of canonical registry names or absolute indexed paths. Entries are trimmed, resolved against the registry, and deduplicated at startup. When exactly one repository is allowed it becomes the implicit default; when several are allowed, callers must select one unless GITNEXUS_MCP_DEFAULT_REPO is also set.

The default repository must resolve to an allowed repository. Invalid, ambiguous, blank, or mismatched configuration fails startup before stdio or HTTP begins serving. The allowlist applies to tools, aliases, discovery, resources, templates, implicit resolution, and embedded HTTP; hidden repository details are not included in selection errors. Setting only GITNEXUS_MCP_DEFAULT_REPO chooses a default without restricting explicit repository selections. An allowed repository whose name is duplicated in the registry must be configured by path, and its context resource is only served for the unique name form.

MCP response budgets

The query, context, and impact tools accept an optional positive-integer maxTokens argument. It bounds the complete formatted MCP response, including hints and error text, using a deterministic four-UTF-8-bytes-per-token estimate. When truncation is required, the response ends with and remains valid UTF-8.

Set GITNEXUS_MCP_DEFAULT_MAX_TOKENS to apply the same guardrail when callers do not send maxTokens. An explicit tool argument takes precedence. Leaving both unset preserves the existing response byte-for-byte; this is a transport guardrail, not semantic pagination or an exact model-specific tokenizer limit.

CLI Reference

Everyday commands:

gitnexus setup                   # Configure MCP for detected editors (one-time; -c to select)
gitnexus analyze [path]          # Index a repository (or update a stale index)
gitnexus mcp                     # Start MCP server (stdio) — serves all indexed repos
gitnexus serve                   # Start local HTTP server (multi-repo) for web UI connection
gitnexus eval-server             # Start lightweight evaluation HTTP tools (loopback by default)
gitnexus list                    # List all indexed repositories
gitnexus status                  # Show index status for current repo
gitnexus clean                   # Delete index for current repo
gitnexus wiki [path]             # Generate repository wiki from knowledge graph
gitnexus uninstall               # Preview removal of GitNexus MCP/skills/hooks (--force to apply)

You can also query the graph directly from the terminal — gitnexus query, context, impact, trace, cypher, detect-changes, and check mirror the MCP tools of the same names, and gitnexus doctor prints runtime platform capabilities.

Authenticated eval-server binding

gitnexus eval-server binds to 127.0.0.1 by default. Loopback bindings do not require authentication. Any non-loopback bind, including 0.0.0.0, a LAN address, or a hostname that resolves to a LAN IPv4 address, requires GITNEXUS_AUTH_TOKEN. Every endpoint then requires an exact Authorization: Bearer <token> header.

GITNEXUS_AUTH_TOKEN='replace-me' gitnexus eval-server --host 0.0.0.0

The token may be set in the shell, .env.local, or .env in the working directory. Precedence is shell > .env.local > .env. Only GITNEXUS_AUTH_TOKEN is read from those files; their other values are not added to the process environment. Keep token files uncommitted.

All analyze flags
gitnexus analyze --force         # Full rebuild: re-parse + graph rebuild + FTS rebuild
gitnexus analyze --repair-fts    # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --skills        # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-embeddings  # Skip embedding generation (faster)
gitnexus analyze --embeddings [limit]  # Enable embedding generation (slower, better search)
gitnexus analyze --skip-agents-md   # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --skip-skills      # Skip installing standard skill files under .claude/skills/ and .agents/skills/
gitnexus analyze --skip-git         # Index folders that are not Git repositories
gitnexus analyze --default-branch develop  # Branch used in the generated regression-compare example (base_ref)
gitnexus analyze --verbose       # Log skipped files when parsers are unavailable
gitnexus analyze --worker-timeout 60  # Increase worker idle timeout for slow parses
gitnexus analyze --workers <n>   # Parse worker pool size (>=1; default: cores-1, capped at 16,
                                 # auto-sized to the repo). 0 is rejected — there is no sequential mode.
gitnexus analyze --wal-checkpoint-threshold 67108864  # LadybugDB WAL auto-checkpoint threshold in bytes
                                 # (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)

If analyze reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use --worker-timeout 60 or set GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000. For very large files, GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES controls the worker job byte budget.

Embeddings node limitgitnexus analyze --embeddings generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories:

gitnexus analyze --embeddings          # default 50,000 node safety cap
gitnexus analyze --embeddings 0        # disable the cap entirely
gitnexus analyze --embeddings 100000   # custom cap

If embeddings are skipped on a large repository, the indexed graph likely exceeds the default cap — re-run with --embeddings 0 or a higher limit.

Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name>                           # Create a repository group
gitnexus group add <group> <groupPath> <registryName>  # Add a repo. <groupPath> is a hierarchy path
                                                       # (e.g. hr/hiring/backend); <registryName> is the
                                                       # repo's name from the registry (see `gitnexus list`)
gitnexus group remove <group> <groupPath>              # Remove a repo by its hierarchy path
gitnexus group list [name]                             # List groups, or show one group's config
gitnexus group sync <name>                             # Extract contracts and match across repos/services
gitnexus group contracts <name>                        # Inspect extracted contracts and cross-links
gitnexus group query <name> <q>                        # Search execution flows across all repos in a group
gitnexus group status <name>                           # Check staleness of repos in a group
gitnexus group impact <name> --target <symbol> --repo <groupPath>  # Cross-repo blast radius
Project config (.gitnexusrc)

Commit a .gitnexusrc JSON file at the repo root to preconfigure recurring analyze options per project, instead of re-passing the same flags every run. It is read from the resolved repo root (not .gitnexus/, which is gitignored index storage). CLI flags always override .gitnexusrc.

{
  // Default branch used in the generated regression-compare example (base_ref).
  // Use this so a project on `develop`/`master` doesn't get "main" rewritten
  // over its fix on every analyze. (Alias: "branch".)
  "defaultBranch": "develop",
  "skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
  "skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/
  "embeddings": true, // generate embeddings by default
  "workerTimeout": 60,
}

A nested analyze block is also accepted (and overrides flat keys for the same option):

{ "analyze": { "defaultBranch": "develop", "skipSkills": true } }

Notes:

  • The default branch is resolved as: --default-branch > .gitnexusrc defaultBranch/branch > auto-detected origin/HEAD > main.
  • skipContextFiles / skipAiContext are aliases for skipAgentsMd — they skip the AGENTS.md / CLAUDE.md block only. They do not imply skipSkills. indexOnly is the stronger option that skips all file injection.
  • Supported keys: defaultBranch (branch), skipAgentsMd (skipContextFiles, skipAiContext), skipSkills, indexOnly, stats/noStats, embeddings, dropEmbeddings, name, allowDuplicateName, maxFileSize, workerTimeout, walCheckpointThreshold, workers, embeddingThreads, embeddingBatchSize, embeddingSubBatchSize, embeddingDevice.
  • The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
Environment variables

Most analyze knobs are also CLI flags (--workers, --worker-timeout, --max-file-size, --verbose). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.

Variable Default Effect Tune when…
GITNEXUS_WORKER_POOL_SIZE cores - 1, capped at 16 Parse worker pool size (must be ≥ 1). Equivalent to --workers <n>. The worker pool is the sole parse path — there is no sequential parser, so 0 is rejected with an actionable error (the pool self-heals via quarantine + respawn). Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set 1 for a single-worker pool — not 0.
GITNEXUS_PARSE_CHUNK_CONCURRENCY 2 Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock.
GITNEXUS_VERBOSE unset When 1, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to --verbose. Debugging an analyze that "completed" but seems to have missed files; tuning --workers / chunk concurrency against observable throughput.
GITNEXUS_AUTH_TOKEN unset Bearer token required when eval-server binds beyond loopback. May also be read from .env.local or .env; shell values take precedence. Exposing the evaluation HTTP tools to a container, VM, or LAN.
GITNEXUS_PROFILE_DEFERRED unset When 1, emits [deferred-profile] timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by GITNEXUS_VERBOSE. Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise.
GITNEXUS_PROFILE_DEFERRED_SLOW_MS 3000 (verbose) / 5000 Per-file threshold in ms above which processCallsFromExtracted emits a slow file … log line. Parsed via Number(): accepts integers (5000), scientific notation (2.5e3), decimals (.5), and hex (0x10). Non-finite or non-positive values fall back to the default. Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst.
PROF_LBUG_LOAD unset When 1, emits one [lbug-load prof] summary line per loadGraphToLbug call breaking the graph-DB persistence wall into stages (csv-emit / copy-nodes / copy-rels / fallback / total) plus node & edge counts. Zero-cost when unset. Attributing large-repo analyze wall time across CSV generation vs. LadybugDB COPY (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path.
GITNEXUS_MAX_FILE_SIZE 512 (KB) Walker skip threshold in KB. Hard cap is 32768 (tree-sitter buffer ceiling). Equivalent to --max-file-size <kb>. Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed.
GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS 30000 Worker idle timeout in milliseconds before retry/fallback. Equivalent to --worker-timeout <seconds> × 1000. Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s.
GITNEXUS_WORKER_READY_TIMEOUT_MS 5000 Startup budget in milliseconds for a parse worker to load its grammar bindings and report {type:'ready'}. Slots that miss it are treated as startup crashes. Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms".
GITNEXUS_FTS_STEMMER porter Stemmer used when rebuilding BM25/FTS indexes. Use none for CJK-heavy repositories, or a language stemmer such as german, french, or spanish for matching repository comments. Re-run gitnexus analyze --repair-fts after changing it. Keyword search quality is poor for non-English comments or identifiers under English stemming.
GITNEXUS_WAL_CHECKPOINT_THRESHOLD 67108864 (64 MiB) LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to --wal-checkpoint-threshold <bytes>. -1 keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload.
GITNEXUS_LBUG_BUFFER_POOL_SIZE min(2 GiB, 80% RAM) LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). 0 restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During analyze the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. A long-lived gitnexus mcp or a big incremental analyze uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB.
GITNEXUS_LBUG_MAX_DB_SIZE 17179869184 (16 GiB) Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB.
GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES 8388608 (8 MB) Per-job byte budget the pool will send to a worker in one postMessage. Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure.
GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT 3 Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped.
GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS 5 × subBatchTimeoutMs Total retry wall-time budget per job before quarantining. Combined with timeoutBackoffFactor, prevents exponentially-growing retries from stalling for hours. Slow files that legitimately need long total retry windows; lower to fail-fast on stalls.
GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD max(3, poolSize) Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly.
GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS 30000 Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with Napi::Error, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise).
GITNEXUS_CPP_CAPTURE_BUDGET_MS 20000 Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). 0 expires immediately. Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast.
GITNEXUS_CHUNK_BYTE_BUDGET 2097152 (2 MB) Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. Tuning incremental-analyze cache behavior on monorepos.
GITNEXUS_NO_GITIGNORE unset When set, skips .gitignore parsing. .gitnexusignore is still honored. Indexing a repo whose .gitignore excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup).
GITNEXUS_SKIP_OPTIONAL_GRAMMARS unset When =1 strictly, skips the vendored grammar materialize for tree-sitter-dart, tree-sitter-proto, tree-sitter-swift, and tree-sitter-kotlin at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing.
GITNEXUS_MCP_READ_ONLY unset Set to 1 to expose only proven single-repository read tools and resources; 0 disables the policy and any other value fails startup. The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable.
GITNEXUS_MCP_ALLOWED_REPOS unset Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. One MCP process must expose only a bounded subset of the repositories in the global registry.
GITNEXUS_MCP_DEFAULT_REPO unset Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. Several repositories are available but unqualified MCP calls should resolve deterministically.
GITNEXUS_MCP_DEFAULT_MAX_TOKENS unset Default positive-integer response budget for MCP query, context, and impact, estimated at four UTF-8 bytes per token. Explicit maxTokens wins. Long MCP responses consume too much model context and callers cannot reliably add a per-request budget.
GITNEXUS_PUBLIC_ORIGIN unset The single browser origin serve is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (0.0.0.0) has no host identity, so without this the server's own UI is refused. Setting it currently refuses to start: serve has no authentication, requests carrying no Origin header already reach POST /api/analyze and DELETE /api/repo, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (app.example.com) means https, since a bare host comes from platform service discovery and those terminate TLS; spell out http://app.example.com for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, *, a bare port number, a :0 port, a trailing dot) warns at startup and allows nothing. gitnexus serve runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return origin_not_allowed.
GITNEXUS_TRUST_PROXY loopback, linklocal, uniquelocal Express trust proxy value — which upstream hops may set X-Forwarded-*, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. false/no/off (and a 0 hop count) trust no hop; a proxy list Express can compile (loopback, 10.0.0.0/8, 127.0.0.1) names them instead. true/yes/on is rejected: it reads the client-controlled leftmost X-Forwarded-For entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (ERR_ERL_PERMISSIVE_TRUST_PROXY). Counts above 16 are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and serve warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. serve sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global.
gitnexus uninstall

gitnexus uninstall reverses gitnexus setup — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified by bundled gitnexus skill name (e.g. gitnexus-cli/), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass --force to apply. Per-repo indexes (gitnexus clean --all) and the global npm package (npm uninstall -g gitnexus) are left for you to remove.

Publishing to understand-quickly (opt-in)

looptech-ai/understand-quickly is a public registry of code-knowledge graphs that lists gitnexus@1 as a first-class format. After registering your repo once (npx @understand-quickly/cli add or the wizard), gitnexus publish fires a single repository_dispatch event so the registry resyncs your entry on demand instead of waiting for the nightly job.

It is opt-in and a no-op without UNDERSTAND_QUICKLY_TOKEN — a fine-grained GitHub PAT with Repository dispatches: write on the registry repo. Nothing else happens; no graph file is uploaded. See the protocol spec for the full contract.

How It Works

GitNexus builds a complete knowledge graph of your codebase through a multi-phase indexing pipeline:

  1. Structure — walks the file tree and maps folder/file relationships
  2. Parsing — extracts functions, classes, methods, and interfaces using Tree-sitter ASTs
  3. Resolution — resolves imports, function calls, heritage, constructor inference, and self/this receiver types across files with language-aware logic
  4. Clustering — groups related symbols into functional communities
  5. Processes — traces execution flows from entry points through call chains
  6. Search — builds hybrid search indexes for fast retrieval

Supported Languages

Language Imports Named Bindings Exports Heritage Type Annotations Constructor Inference Config Frameworks Entry Points
TypeScript
JavaScript
Python
Java
Kotlin
C#
Go
Rust
PHP
Ruby
Swift
C
C++
Dart

Imports — cross-file import resolution · Named Bindingsimport { X as Y } / re-export tracking · Exports — public/exported symbol detection · Heritage — class inheritance, interfaces, mixins · Type Annotations — explicit type extraction for receiver resolution · Constructor Inference — infer receiver type from constructor calls (self/this resolution included for all languages) · Config — language toolchain config parsing (tsconfig, go.mod, etc.) · Frameworks — AST-based framework pattern detection · Entry Points — entry point scoring heuristics

Control flow (CFG, opt-in --pdg) — per-function control-flow graphs (BasicBlock nodes + CFG edges) feeding the PDG/taint substrate, currently TypeScript & JavaScript (#2081 M1); other languages planned. Off by default.

Multi-Repo Architecture

GitNexus uses a global registry so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.

Each gitnexus analyze stores the index in .gitnexus/ inside the repo (portable, gitignored) and registers a pointer in ~/.gitnexus/registry.json. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the repo parameter is optional on all tools — agents don't need to change anything.

Architecture diagram
flowchart TD
    subgraph CLI [CLI Commands]
        Setup["gitnexus setup"]
        Analyze["gitnexus analyze"]
        Clean["gitnexus clean"]
        List["gitnexus list"]
    end

    subgraph Registry ["~/.gitnexus/"]
        RegFile["registry.json"]
    end

    subgraph Repos [Project Repos]
        RepoA[".gitnexus/ in repo A"]
        RepoB[".gitnexus/ in repo B"]
    end

    subgraph MCP [MCP Server]
        Server["server.ts"]
        Backend["LocalBackend"]
        Pool["Connection Pool"]
        ConnA["LadybugDB conn A"]
        ConnB["LadybugDB conn B"]
    end

    Setup -->|"writes global MCP config"| CursorConfig["~/.cursor/mcp.json"]
    Analyze -->|"registers repo"| RegFile
    Analyze -->|"stores index"| RepoA
    Clean -->|"unregisters repo"| RegFile
    List -->|"reads"| RegFile
    Server -->|"reads registry"| RegFile
    Server --> Backend
    Backend --> Pool
    Pool -->|"lazy open"| ConnA
    Pool -->|"lazy open"| ConnB
    ConnA -->|"queries"| RepoA
    ConnB -->|"queries"| RepoB

Tool Examples

Impact Analysis

impact({target: "UserService", direction: "upstream", minConfidence: 0.8})

TARGET: Class UserService (src/services/user.ts)

UPSTREAM (what depends on this):
  Depth 1 (WILL BREAK):
    handleLogin [CALLS 90%] -> src/api/auth.ts:45
    handleRegister [CALLS 90%] -> src/api/auth.ts:78
    UserController [CALLS 85%] -> src/controllers/user.ts:12
  Depth 2 (LIKELY AFFECTED):
    authRouter [IMPORTS] -> src/routes/auth.ts

Options: maxDepth, minConfidence, relationTypes (CALLS, IMPORTS, EXTENDS, IMPLEMENTS), includeTests, limit (max symbols per depth, default 100), offset (pagination start per depth), summaryOnly (counts and risk only, omits symbol list)

Disambiguation — when several symbols share the target name, impact returns a ranked ambiguous candidate list instead of guessing. Narrow it with target_uid (exact, zero-ambiguity), file_path, or kind (Function, Class, Method, …). From the CLI these are --uid, --file, and --kind, matching gitnexus context:

gitnexus impact get_embeddings                       # → ambiguous: lists ranked candidates
gitnexus impact get_embeddings --file src/embed.py   # → resolves to the one in that file
gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings"  # exact
More examples: search · context · detect_changes · rename · Cypher
query({search_query: "authentication middleware"})

processes:
  - summary: "LoginFlow"
    priority: 0.042
    symbol_count: 4
    process_type: cross_community
    step_count: 7

process_symbols:
  - name: validateUser
    type: Function
    filePath: src/auth/validate.ts
    process_id: proc_login
    step_index: 2

definitions:
  - name: AuthConfig
    type: Interface
    filePath: src/types/auth.ts

Context (360-degree Symbol View)

context({name: "validateUser"})

symbol:
  uid: "Function:validateUser"
  kind: Function
  filePath: src/auth/validate.ts
  startLine: 15

incoming:
  calls: [handleLogin, handleRegister, UserController]
  imports: [authRouter]

outgoing:
  calls: [checkPassword, createSession]

processes:
  - name: LoginFlow (step 2/7)
  - name: RegistrationFlow (step 3/5)

Detect Changes (Pre-Commit)

detect_changes({scope: "all"})

summary:
  changed_count: 12
  affected_count: 3
  changed_files: 4
  risk_level: medium

changed_symbols: [validateUser, AuthService, ...]
affected_processes: [LoginFlow, RegistrationFlow, ...]

Rename (Multi-File)

rename({symbol_name: "validateUser", new_name: "verifyUser", dry_run: true})

status: success
files_affected: 5
total_edits: 8
graph_edits: 6     (high confidence)
text_search_edits: 2  (review carefully)
changes: [...]

Cypher Queries

-- Find what calls auth functions with high confidence
MATCH (c:Community {heuristicLabel: 'Authentication'})<-[:CodeRelation {type: 'MEMBER_OF'}]-(fn)
MATCH (caller)-[r:CodeRelation {type: 'CALLS'}]->(fn)
WHERE r.confidence > 0.8
RETURN caller.name, fn.name, r.confidence
ORDER BY r.confidence DESC

Wiki Generation

Generate LLM-powered documentation from your knowledge graph:

# Requires an LLM API key (OPENAI_API_KEY, etc.)
gitnexus wiki

# Use a custom model or provider (default model: minimax/minimax-m2.5)
gitnexus wiki --model gpt-4o
gitnexus wiki --base-url https://api.anthropic.com/v1

# Force full regeneration
gitnexus wiki --force

# Increase the timeout or retries for large codebases or slow LLM providers
gitnexus wiki --timeout <seconds>  # LLM request timeout in seconds (default: disabled)
gitnexus wiki --retries <n>        # Max LLM retry attempts per request (default: 3)

# Allow a specific LAN/self-hosted HTTP LLM host (HTTPS is preferred for remote endpoints)
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
# Or set a comma-separated host allowlist:
GITNEXUS_ALLOW_INSECURE_CONNECTION=llama-box.local,192.168.1.23

# Change the output language
gitnexus wiki --lang <lang>  # e.g. english, chinese, spanish, japanese

For safety, http:// LLM base URLs are allowed by default only for loopback hosts (localhost, 127.0.0.1, ::1). --allow-insecure-connection and GITNEXUS_ALLOW_INSECURE_CONNECTION accept exact hostnames or IP addresses only; do not include schemes, ports, paths, credentials, or wildcards.

The wiki generator reads the indexed graph structure, groups files into modules via LLM, generates per-module documentation pages, and creates an overview page — all with cross-references to the knowledge graph.

Web UI (browser-based)

A client-side graph explorer and AI chat — your code never leaves your machine.

Try it now: gitnexus.vercel.app — run npx gitnexus@latest serve locally and the page auto-connects to your local backend.

gitnexus_img

The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.

Local Backend Mode: run gitnexus serve and open the web UI — it auto-detects the server and shows all your indexed repos, with full AI chat support. No re-upload, no re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.

Run the frontend locally
git clone https://github.com/abhigyanpatwari/gitnexus.git
cd gitnexus/gitnexus-shared && npm install && npm run build
cd ../gitnexus-web && npm install
npm run dev
# Then in another terminal, start the backend the frontend connects to:
npx gitnexus@latest serve

Docker

docker compose up -d

This starts the server on http://localhost:4747 and the web UI on http://localhost:4173. The UI auto-detects the server because the browser runs on the host and reaches the container via the mapped port.

The official setup ships two signed images, published identically to GitHub Container Registry (GHCR) and Docker Hub — same build, same digest, same Cosign signature:

Purpose GHCR (default in docker-compose.yaml) Docker Hub mirror
CLI / gitnexus serve backend (HTTP API on port 4747, MCP, indexer) ghcr.io/abhigyanpatwari/gitnexus:latest akonlabs/gitnexus:latest
Static web UI (port 4173) ghcr.io/abhigyanpatwari/gitnexus-web:latest akonlabs/gitnexus-web:latest

A named volume (gitnexus-data) persists the global registry, indexes, and cloned repos at /data/gitnexus inside the server container. To make repos on your host machine indexable, set WORKSPACE_DIR before bringing the stack up:

WORKSPACE_DIR=$HOME/code docker compose up -d
# Inside the server container the directory is mounted read-only at /workspace.
docker compose exec gitnexus-server gitnexus index /workspace/my-repo

Heads-up — image rename. Earlier releases published the web UI under ghcr.io/abhigyanpatwari/gitnexus. That slug now hosts the CLI/server image and the UI moved to ghcr.io/abhigyanpatwari/gitnexus-web. Previous tags remain pullable, but new versions are only published under the new slugs — update your docker run / compose files (or just adopt the bundled compose).

Direct docker run & env file
# Server
docker run --rm -d \
  --name gitnexus-server \
  -p 4747:4747 \
  -v gitnexus-data:/data/gitnexus \
  ghcr.io/abhigyanpatwari/gitnexus:latest

# Web UI
docker run --rm -d \
  --name gitnexus-web \
  -p 4173:4173 \
  ghcr.io/abhigyanpatwari/gitnexus-web:latest

Optional env file (override image tags, container names, ports, workspace dir):

cp .env.example .env
docker compose --env-file .env up -d

Files:

  • Dockerfile.web — builds gitnexus-shared and gitnexus-web, then serves the production frontend.
  • Dockerfile.cli — builds the CLI/server (with its native deps) and runs gitnexus serve --host 0.0.0.0.
  • docker-compose.yaml — starts both signed images side by side.
  • .env.example — overrides for image names, container names, ports, and the workspace mount.
Versioning & supply-chain protection (Cosign signatures, provenance, Kubernetes admission policy)

The Docker images are version-locked to the npm package:

  • Stable images are only published from vX.Y.Z git tags (via docker.yml triggered directly by the tag push), and the workflow refuses to build unless the tag exactly matches gitnexus/package.json's version. So ghcr.io/abhigyanpatwari/gitnexus:1.6.2 (and its Docker Hub mirror akonlabs/gitnexus:1.6.2) is byte-for-byte the same release as npm install gitnexus@1.6.2 — no drift, no floating builds from main. Both registries receive the same digest from a single build step, so you can pull from either and the signature verifies identically.
  • Release-candidate images (e.g. :1.7.0-rc.1) are published alongside each RC npm release. They are built by publish.yml calling docker.yml as a reusable workflow after the RC tag is created and pushed.
  • :latest is auto-promoted only from non-prerelease tags by the Docker metadata action, so it always points at a real, npm-published version.

Both images are signed with Cosign keyless signing using the workflow's GitHub OIDC identity, and shipped with build provenance and SBOM attestations. This is your protection against supply-chain attacks: even if an attacker republishes a same-named image elsewhere (or somehow pushes to a typo-squatted registry), they cannot forge a Cosign signature tied to abhigyanpatwari/GitNexus's docker.yml. Always verify before pulling into sensitive environments.

Stable releases — signed from the v* tag ref:

cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \
  --certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

# Same signature verifies the Docker Hub mirror (identical digest):
cosign verify docker.io/akonlabs/gitnexus:1.6.2 \
  --certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

The regex pins the certificate identity to this repo's docker.yml workflow run from a v* tag — rejecting unsigned images, images signed by other workflows, and images signed from unprotected refs. It is identical for both registries because both sets of tags were signed at the same digest in one workflow run.

Release candidates — signed from refs/heads/main (the caller's ref when publish.yml invokes docker.yml as a reusable workflow):

cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \
  --certificate-identity 'https://github.com/abhigyanpatwari/GitNexus/.github/workflows/docker.yml@refs/heads/main' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

You can also inspect the build provenance and SBOM:

cosign download attestation ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \
  --predicate-type https://slsa.dev/provenance/v1

Kubernetes: enforce signatures at admission. Ship the bundled ClusterImagePolicy so the Sigstore policy-controller rejects any GitNexus pod whose image is not signed by this repo's docker.yml running from a vX.Y.Z tag — the same identity the cosign verify snippet above pins.

# 1. Install the controller (one-time, cluster-wide)
helm repo add sigstore https://sigstore.github.io/helm-charts && helm repo update
helm install policy-controller -n cosign-system --create-namespace \
  sigstore/policy-controller

# 2. Opt your namespace in
kubectl label namespace <your-ns> policy.sigstore.dev/include=true

# 3. Apply the policy
kubectl apply -f deploy/kubernetes/cluster-image-policy.yaml

After this, attempting to deploy an unsigned image — or one signed by anything other than abhigyanpatwari/GitNexus's docker.yml at a v* tag — fails the admission webhook before a pod is ever created. This turns the verifiable signature into an enforced policy, which is the supply-chain control most clusters actually need.

Enterprise

GitNexus is available as an enterprise offering — fully managed SaaS or self-hosted deployment. Commercial use of the OSS version is also available with proper licensing.

Enterprise includes:

  • PR Review — automated blast radius analysis on pull requests
  • Auto-updating Code Wiki — always up-to-date documentation (Code Wiki is also available in OSS)
  • Auto-reindexing — knowledge graph stays fresh automatically
  • Multi-repo support — unified graph across repositories
  • OCaml support — additional language coverage
  • Priority feature/language support — request new languages or features

Upcoming: auto regression forensics · end-to-end test generation

👉 Learn more at akonlabs.com — for commercial licensing or enterprise inquiries, ping us on Discord or email founders@akonlabs.com

Community Integrations

Built by the community — not officially maintained, but worth checking out.

Project Author Description
pi-gitnexus @tintinweb GitNexus plugin for pipi install npm:pi-gitnexus
gitnexus-stable-ops @ShunsukeHayashi Stable ops & deployment workflows (Miyabi ecosystem)
KiloCode MCP workflow @oktanishq Guide to connect GitNexus MCP to Kilo Code and verify tools.

Have a project built on GitNexus? Open a PR to add it here!

Roadmap

Actively building:

  • LLM Cluster Enrichment — semantic cluster names via LLM API
  • AST Decorator Detection — parse @Controller, @Get, etc.
  • Incremental Indexing — only re-index changed files

Recently completed:

  • Constructor-Inferred Type Resolution, self/this Receiver Mapping
  • Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis
  • Process-Grouped Search, 360-Degree Context, Claude Code Hooks
  • Multi-Repo MCP, Zero-Config Setup, 14 Language Support
  • Community Detection, Process Detection, Confidence Scoring
  • Hybrid Search, Vector Index

Development

  • ARCHITECTURE.md — packages, index → graph → MCP flow, where to change code
  • RUNBOOK.md — analyze, embeddings, stale index, MCP recovery, CI snippets
  • GUARDRAILS.md — safety rules and operational "Signs" for contributors and agents
  • CONTRIBUTING.md — license, setup, commits, and pull requests
  • TESTING.md — test commands for gitnexus and gitnexus-web

Tech Stack

Layer CLI Web
Runtime Node.js (native) Browser (WASM)
Parsing Tree-sitter native bindings Tree-sitter WASM
Database LadybugDB native LadybugDB WASM
Embeddings HuggingFace transformers.js (GPU/CPU) transformers.js (WebGPU/WASM)
Search BM25 + semantic + RRF BM25 + semantic + RRF
Agent Interface MCP (stdio) LangChain ReAct agent
Visualization Sigma.js + Graphology (WebGL)
Frontend React 18, TypeScript, Vite, Tailwind v4
Clustering Graphology Graphology
Concurrency Worker threads + async Web Workers + Comlink

Security & Privacy

  • CLI: everything runs locally on your machine. No network calls. Index stored in .gitnexus/ (gitignored). Global registry at ~/.gitnexus/ stores only paths and metadata.
  • Web: everything runs in your browser. No code uploaded to any server. API keys stored in localStorage only.
  • Open source — audit the code yourself.

Star History

Star History Chart

Acknowledgments