fix(mcp): name the indexed ref on hot read tool staleness (#3291) (#3293)

This commit is contained in:
Gergő Magyar 2026-09-16 08:29:04 +01:00 • committed by GitHub
parent 66421ef42f
commit ebbcd5b0b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 767 additions and 79 deletions

View file

@ -42,6 +42,7 @@ diagnosis.
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist

View file

@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation.
```
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist

View file

@ -16,6 +16,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
> On `query` / `context` / `impact` / `cypher`, read `staleness.status` and `staleness.branch`/`lastCommit` before using the answer. Re-analyze only for `behind` or `diverged`.
## Skills
@ -83,11 +84,32 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported).
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
These four hot read tools attach a non-blocking `staleness` field to their response when the index is not at the checkout's current HEAD — the same `{ status, commitsBehind?, hint? }` shape `list_repos` already reports — so a direct tool call surfaces a stale index without a separate `list_repos` call:
These four hot read tools attach a non-blocking `staleness` field to every response, in the shape `{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }`. It answers two different questions at once: **which index answered** and **how fresh it is**. The identity half is why the field is present even when nothing is wrong — an answer computed from a branch-pinned index is otherwise indistinguishable from one computed from the default branch (#3291):
```jsonc
{ /* …the tool's normal result… */
"staleness": { "status": "behind", "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
"staleness": {
"status": "current",
"branch": "feature/checkout-v2",
"lastCommit": "4f2a1c9e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d",
"indexedAt": "2026-09-15T07:12:00.000Z",
"measuredAgainst": "HEAD"
}
}
```
`status: "current"` here means *this index is at the HEAD of the clone it was built from* — not that it is current with the default branch. `measuredAgainst` names what `commitsBehind` is counted against: the checked-out HEAD of that clone, never the remote. `branch` is the branch the index represents; it is absent for a detached HEAD, a non-git folder, or a legacy index that never recorded one, so read `lastCommit` when you need an identifier that is always present.
When the index is behind that HEAD, the count and hint ride along:
```jsonc
{ /* …the tool's normal result… */
"staleness": {
"status": "behind", "commitsBehind": 3, "branch": "main",
"lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
"indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
"hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update."
}
}
```
@ -95,11 +117,18 @@ These four hot read tools attach a non-blocking `staleness` field to their respo
```jsonc
{ /* …the tool's normal result… */
"staleness": { "status": "diverged", "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." }
"staleness": {
"status": "diverged", "branch": "main",
"lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
"indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
"hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update."
}
}
```
The field is **absent when the index is current**, and these four tools also omit it when the freshness check could not run at all — that case is `status: "unknown"`, which only the `list_repos` listing reports. So its presence means the status is not `current`: read `status` before using `commitsBehind`. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
So: **read `status` before using `commitsBehind`**, and read `branch`/`lastCommit` before assuming which ref the answer describes. `status: "unknown"` means the freshness check could not run at all (a `--skip-git` folder has no history to measure) — the ref is still reported, because which index answered is knowable even when its freshness is not. The field is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). Re-run `analyze` only for `behind` or `diverged` — those mean the index is not at this clone's HEAD. `unknown` is unmeasurable, not stale; analyze cannot make it `current` unless git history exists.
`list_repos` and the HTTP repo routes are unchanged: they omit `staleness` entirely for a current index and report the ref through their own top-level `branch` / `lastCommit` / `indexedAt` fields.
### Taint findings (`explain`)

View file

@ -53,6 +53,7 @@ Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEA
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
> If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands.
## Checklist

View file

@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklists

View file

@ -116,6 +116,7 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows).
> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
> On query/context/impact/cypher object results, read staleness.status and branch/lastCommit. Re-analyze only for behind or diverged — current is clone HEAD, not main.
## Always Do

View file

@ -65,6 +65,7 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows).
> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
> On query/context/impact/cypher object results, read staleness.status and branch/lastCommit. Re-analyze only for behind or diverged — current is clone HEAD, not main.
## Always Do

View file

@ -42,6 +42,7 @@ diagnosis.
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist

View file

@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation.
```
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist

View file

@ -16,6 +16,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
> On `query` / `context` / `impact` / `cypher`, read `staleness.status` and `staleness.branch`/`lastCommit` before using the answer. Re-analyze only for `behind` or `diverged`.
## Skills
@ -83,11 +84,32 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported).
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
These four hot read tools attach a non-blocking `staleness` field to their response when the index is not at the checkout's current HEAD — the same `{ status, commitsBehind?, hint? }` shape `list_repos` already reports — so a direct tool call surfaces a stale index without a separate `list_repos` call:
These four hot read tools attach a non-blocking `staleness` field to every response, in the shape `{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }`. It answers two different questions at once: **which index answered** and **how fresh it is**. The identity half is why the field is present even when nothing is wrong — an answer computed from a branch-pinned index is otherwise indistinguishable from one computed from the default branch (#3291):
```jsonc
{ /* …the tool's normal result… */
"staleness": { "status": "behind", "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
"staleness": {
"status": "current",
"branch": "feature/checkout-v2",
"lastCommit": "4f2a1c9e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d",
"indexedAt": "2026-09-15T07:12:00.000Z",
"measuredAgainst": "HEAD"
}
}
```
`status: "current"` here means *this index is at the HEAD of the clone it was built from* — not that it is current with the default branch. `measuredAgainst` names what `commitsBehind` is counted against: the checked-out HEAD of that clone, never the remote. `branch` is the branch the index represents; it is absent for a detached HEAD, a non-git folder, or a legacy index that never recorded one, so read `lastCommit` when you need an identifier that is always present.
When the index is behind that HEAD, the count and hint ride along:
```jsonc
{ /* …the tool's normal result… */
"staleness": {
"status": "behind", "commitsBehind": 3, "branch": "main",
"lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
"indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
"hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update."
}
}
```
@ -95,11 +117,18 @@ These four hot read tools attach a non-blocking `staleness` field to their respo
```jsonc
{ /* …the tool's normal result… */
"staleness": { "status": "diverged", "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." }
"staleness": {
"status": "diverged", "branch": "main",
"lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
"indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
"hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update."
}
}
```
The field is **absent when the index is current**, and these four tools also omit it when the freshness check could not run at all — that case is `status: "unknown"`, which only the `list_repos` listing reports. So its presence means the status is not `current`: read `status` before using `commitsBehind`. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
So: **read `status` before using `commitsBehind`**, and read `branch`/`lastCommit` before assuming which ref the answer describes. `status: "unknown"` means the freshness check could not run at all (a `--skip-git` folder has no history to measure) — the ref is still reported, because which index answered is knowable even when its freshness is not. The field is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). Re-run `analyze` only for `behind` or `diverged` — those mean the index is not at this clone's HEAD. `unknown` is unmeasurable, not stale; analyze cannot make it `current` unless git history exists.
`list_repos` and the HTTP repo routes are unchanged: they omit `staleness` entirely for a current index and report the ref through their own top-level `branch` / `lastCommit` / `indexedAt` fields.
### Taint findings (`explain`)

View file

@ -53,6 +53,7 @@ Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEA
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
> If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands.
## Checklist

View file

@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklists

View file

@ -42,6 +42,7 @@ diagnosis.
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist

View file

@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation.
```
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist

View file

@ -53,6 +53,7 @@ Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEA
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
> If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands.
## Checklist

View file

@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklists

View file

@ -4,6 +4,10 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Changed
- **MCP `query` / `context` / `impact` / `cypher` always attach a ref-carrying `staleness` field** — object results include it even when `status` is `current`. Absence is no longer the freshness signal: read `staleness.status` (`behind`/`diverged` vs `current`/`unknown`) and `branch`/`lastCommit` for which index answered. `list_repos` and the HTTP repo routes are unchanged (still omit `staleness` when current; the ref is top-level) (#3291, #3293)
## [1.6.12] - 2026-09-12
### Added

View file

@ -42,6 +42,7 @@ diagnosis.
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist

View file

@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation.
```
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklist

View file

@ -16,6 +16,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
> On `query` / `context` / `impact` / `cypher`, read `staleness.status` and `staleness.branch`/`lastCommit` before using the answer. Re-analyze only for `behind` or `diverged`.
## Skills
@ -81,6 +82,54 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
These four hot read tools attach a non-blocking `staleness` field to every response, in the shape `{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }`. It answers two different questions at once: **which index answered** and **how fresh it is**. The identity half is why the field is present even when nothing is wrong — an answer computed from a branch-pinned index is otherwise indistinguishable from one computed from the default branch (#3291):
```jsonc
{ /* …the tool's normal result… */
"staleness": {
"status": "current",
"branch": "feature/checkout-v2",
"lastCommit": "4f2a1c9e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d",
"indexedAt": "2026-09-15T07:12:00.000Z",
"measuredAgainst": "HEAD"
}
}
```
`status: "current"` here means *this index is at the HEAD of the clone it was built from* — not that it is current with the default branch. `measuredAgainst` names what `commitsBehind` is counted against: the checked-out HEAD of that clone, never the remote. `branch` is the branch the index represents; it is absent for a detached HEAD, a non-git folder, or a legacy index that never recorded one, so read `lastCommit` when you need an identifier that is always present.
When the index is behind that HEAD, the count and hint ride along:
```jsonc
{ /* …the tool's normal result… */
"staleness": {
"status": "behind", "commitsBehind": 3, "branch": "main",
"lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
"indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
"hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update."
}
}
```
`commitsBehind` is present only when git counted the gap. When git could not count it but HEAD still resolves to a commit other than the indexed one — usually because the indexed commit is no longer in the clone's history — the index is provably not at HEAD with no countable gap, so no number is reported:
```jsonc
{ /* …the tool's normal result… */
"staleness": {
"status": "diverged", "branch": "main",
"lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
"indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
"hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update."
}
}
```
So: **read `status` before using `commitsBehind`**, and read `branch`/`lastCommit` before assuming which ref the answer describes. `status: "unknown"` means the freshness check could not run at all (a `--skip-git` folder has no history to measure) — the ref is still reported, because which index answered is knowable even when its freshness is not. The field is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). Re-run `analyze` only for `behind` or `diverged` — those mean the index is not at this clone's HEAD. `unknown` is unmeasurable, not stale; analyze cannot make it `current` unless git history exists.
`list_repos` and the HTTP repo routes are unchanged: they omit `staleness` entirely for a current index and report the ref through their own top-level `branch` / `lastCommit` / `indexedAt` fields.
### Taint findings (`explain`)
`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.

View file

@ -53,6 +53,7 @@ Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEA
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
> If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands.
## Checklist

View file

@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable.
## Checklists

View file

@ -221,6 +221,7 @@ ${tableBody}`
This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}.
> Index stale? Run \`${runner} analyze --index-only\` from the project root — it auto-selects an available runner. ${bootstrapNote}
> On query/context/impact/cypher object results, read staleness.status and branch/lastCommit. Re-analyze only for behind or diverged — current is clone HEAD, not main.
## Always Do

View file

@ -52,17 +52,52 @@ export interface StalenessInfo {
export const stalenessStatus = (info: StalenessInfo): StalenessStatus =>
info.status ?? (info.isStale ? 'behind' : 'current');
/**
* The ref an index represents, as the resolved repo handle already knows it.
* `lastCommit` and `indexedAt` are always recorded on a handle; `branch` is
* best-effort — a plain analyze stamps the checked-out branch, but a detached
* HEAD, a non-git folder, or a legacy index that never recorded one leaves it
* absent (`run-analyze.ts`: `branchLabel ?? existingMeta?.branch`).
*/
export interface IndexedRef {
branch?: string;
lastCommit: string;
indexedAt: string;
}
/**
* The wire shape for staleness on every surface: MCP `list_repos`, the hot read
* tools, and the `serve` repo routes. One builder so one fact has one shape
* (#3232 review: "same sentinel as MCP").
*
* Absent for `current`, as before. `commitsBehind` is present only when git
* actually counted it, so `diverged` carries `status` and `hint` but no number —
* inventing one would be the silent wrong answer this exists to remove.
* Two forms, chosen by whether the caller supplies a {@link IndexedRef}:
*
* - **Without a ref** — absent for `current`, as before. That is what
* `list_repos` and the `serve` routes emit; they already report the ref
* through their own top-level `branch` / `lastCommit` / `indexedAt` fields
* (#3226), so repeating it inside the payload would duplicate it.
* - **With a ref** — emitted for EVERY status, naming the index it describes.
* The hot read tools have nowhere else to put it: `attachToolStaleness` may
* add exactly one key to an arbitrary tool result. Without it `current` is
* indistinguishable between an index of the default branch and one of some
* feature branch, because `current` is a statement about a *ref*, not about
* the repository (#3291).
*
* `commitsBehind` is present only when git actually counted it, so `diverged`
* carries `status` and `hint` but no number — inventing one would be the silent
* wrong answer this exists to remove.
*/
export interface StalenessPayload {
status: Exclude<StalenessStatus, 'current'>;
status: StalenessStatus;
/** Ref identity — present only on the ref-carrying (hot read tool) form. */
branch?: string;
lastCommit?: string;
indexedAt?: string;
/**
* What `commitsBehind` is counted against: the checked-out HEAD of the clone
* this index was built from, never the remote or the default branch.
*/
measuredAgainst?: 'HEAD';
commitsBehind?: number;
hint?: string;
}
@ -72,18 +107,36 @@ export interface StalenessPayload {
* nothing to report.
*
* `unknown` is emitted only when `includeUnknown` is set. A listing a monitor
* reads wants it; the hot read tools do not, because a `--skip-git` folder has
* no history to measure and would otherwise repeat that on every response.
* reads wants it; the no-ref hot-tool form does not, because a `--skip-git`
* folder has no history to measure and would otherwise repeat that on every
* response. The ref-carrying form reports it regardless — which index answered
* is knowable even when its freshness is not.
*/
export const stalenessPayload = (
info: StalenessInfo | undefined,
opts: { includeUnknown?: boolean } = {},
opts: { includeUnknown?: boolean; ref?: IndexedRef } = {},
): StalenessPayload | undefined => {
if (!info) return undefined;
const status = stalenessStatus(info);
const hint = info.hint ? { hint: info.hint } : {};
if (status === 'current') return undefined;
if (status === 'unknown') return opts.includeUnknown ? { status } : undefined;
if (status === 'diverged') return { status, ...hint };
return { status, commitsBehind: info.commitsBehind, ...hint };
// No ref: bit-identical to the pre-#3291 output for every status. This is
// what keeps `list_repos` and both `serve` routes byte-stable, and their
// exact-match tests passing unmodified.
if (!opts.ref) {
if (status === 'current') return undefined;
if (status === 'unknown') return opts.includeUnknown ? { status } : undefined;
if (status === 'diverged') return { status, ...hint };
return { status, commitsBehind: info.commitsBehind, ...hint };
}
const ref = {
...(opts.ref.branch ? { branch: opts.ref.branch } : {}),
lastCommit: opts.ref.lastCommit,
indexedAt: opts.ref.indexedAt,
measuredAgainst: 'HEAD' as const,
};
if (status === 'current' || status === 'unknown') return { status, ...ref };
if (status === 'diverged') return { status, ...ref, ...hint };
return { status, ...ref, commitsBehind: info.commitsBehind, ...hint };
};

View file

@ -106,6 +106,7 @@ import {
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
import {
stalenessPayload,
type IndexedRef,
type StalenessInfo,
type StalenessPayload,
} from '../../core/staleness-status.js';
@ -1079,7 +1080,11 @@ interface RepoHandle {
lastCommit: string;
remoteUrl?: string;
stats?: RegistryEntry['stats'];
/** Primary/flat branch name, when known (#2106). */
/**
* Branch this handle's index describes (#2106/#3291). The flat/workspace
* slot keeps the primary checkout name; `applyBranchScope` overwrites it
* with the pin when serving a `branches[]` sub-index.
*/
branch?: string;
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
branches?: BranchSummary[];
@ -1463,18 +1468,29 @@ function canCarryStaleness(result: unknown): result is Record<string, unknown> {
}
/**
* #2655: attach a non-blocking `staleness` signal to a tool result when the
* index is not at HEAD, in the same {@link stalenessPayload} shape `list_repos`
* returns. Only ever ADDS a field to a carryable object result (see
* {@link canCarryStaleness}) — it never changes an existing result's shape.
* #2655: attach a non-blocking `staleness` signal to a tool result. Only ever
* ADDS a field to a carryable object result (see {@link canCarryStaleness}) —
* it never changes an existing result's shape.
*
* `diverged` is attached: it is a positive finding that the index is not at
* HEAD, only uncountable. `unknown` is not — these are the hot read tools, and a
* `--skip-git` folder has no history to measure, so it would ride on every
* response as noise rather than signal (#3256).
* #3291: `ref` names the index the answer came from. Supplying it switches the
* payload to the ref-carrying form, which reports every status — including
* `current` and `unknown` — because that one added key is the only place a tool
* result can say WHICH index answered. Absence used to be the freshness signal
* here; it could not distinguish a current index of the default branch from a
* current index of some feature branch, since `current` is a statement about a
* ref rather than about the repository.
*
* With no `ref` the pre-#3291 behaviour is unchanged: absent for `current`,
* `diverged` attached as a positive finding that the index is not at HEAD, and
* `unknown` withheld as noise (#3256). A missing `info` still attaches nothing
* either way, which is what keeps a failed freshness probe non-fatal.
*/
export function attachToolStaleness(result: unknown, info: StalenessInfo | undefined): unknown {
const staleness = stalenessPayload(info);
export function attachToolStaleness(
result: unknown,
info: StalenessInfo | undefined,
ref?: IndexedRef,
): unknown {
const staleness = stalenessPayload(info, ref ? { ref } : {});
if (!staleness || !canCarryStaleness(result)) {
return result;
}
@ -2137,6 +2153,8 @@ export class LocalBackend {
indexedAt: summary.indexedAt,
lastCommit: summary.lastCommit,
stats: summary.stats,
// The handle now represents the pin, not the flat slot (#3291).
branch: summary.branch,
};
}
// Stale summary (sub-index adopted/deleted): refresh so later calls see
@ -2669,15 +2687,27 @@ export class LocalBackend {
* skipping the `git` spawn entirely for results that can't carry it (error
* envelopes, arrays, non-objects — see {@link canCarryStaleness}) so an
* error-returning call pays nothing.
*
* #3291: the ref comes straight off the already-resolved handle, so naming
* the index costs no extra I/O — no git spawn, no metadata read, and the
* `stalenessForTool` TTL cache is untouched. `branch` is passed through as-is
* and is legitimately absent for a detached HEAD or a legacy index; the
* always-present `lastCommit` is what identifies the index in that case.
*/
private async withToolStaleness(repo: RepoHandle, result: unknown): Promise<unknown> {
if (!canCarryStaleness(result)) return result;
// Defensive: `checkStalenessAsync` self-catches today, but a rejection here
// must never fail the tool — degrade to no-staleness. Paired with the
// evict-on-reject in `stalenessForTool`, a transient failure also can't
// poison the TTL cache entry (#2655 review F1).
// poison the TTL cache entry (#2655 review F1). A rejection leaves `info`
// undefined, and the builder returns nothing for that even with a ref, so
// the degraded path still attaches no field.
const staleness = await this.stalenessForTool(repo).catch(() => undefined);
return attachToolStaleness(result, staleness);
return attachToolStaleness(result, staleness, {
branch: repo.branch,
lastCommit: repo.lastCommit,
indexedAt: repo.indexedAt,
});
}
/**

View file

@ -88,6 +88,10 @@ const CWD_AWARE_REPO_OMISSION =
const MUTATING_REPO_OMISSION =
'Omit only when one repo is indexed or an MCP default is configured; otherwise mutating tools require an explicit repo.';
/** Always-on identity+freshness field on query/context/impact/cypher object results (#3291). */
const HOT_READ_STALENESS_NOTE =
"Object results attach `staleness` even when current. Read `staleness.branch`/`lastCommit` for which index answered and `status` for freshness. Re-analyze only for `behind` or `diverged` — `current` is this clone's HEAD, not necessarily the default branch; `unknown` is unmeasurable, not stale. Field is only on object results (not raw-array cypher, error envelopes, or `@group` calls).";
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
@ -143,7 +147,9 @@ Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank
GROUP MODE: set "repo" to "@<groupName>" to search all member repos in that group (merged via RRF), or "@<groupName>/<groupRepoPath>" to run against a single member (same path keys as in group.yaml). If you use "@<groupName>" only, the member repo defaults to the lexicographically first key in group.yaml "repos". Prefer resources for contracts/status (see migration from legacy group_* tools).
SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server.`,
SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server.
${HOT_READ_STALENESS_NOTE}`,
annotations: QUERY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
@ -254,7 +260,9 @@ TIPS:
- Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy
- Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId
- Use heuristicLabel (not label) for human-readable community/process names
- PDG layers (only when indexed with \`--pdg\`): BasicBlock nodes + CFG / CDG (control dependence, branch sense 'T'|'F' in reason) / REACHING_DEF (def→use, variable in reason) edges, all BasicBlock→BasicBlock. Prefer the \`pdg_query\` tool — it anchors + bounds these for you (raw \`[:CDG*]\`/\`[:REACHING_DEF*]\` path scans are unindexed and unbounded).`,
- PDG layers (only when indexed with \`--pdg\`): BasicBlock nodes + CFG / CDG (control dependence, branch sense 'T'|'F' in reason) / REACHING_DEF (def→use, variable in reason) edges, all BasicBlock→BasicBlock. Prefer the \`pdg_query\` tool — it anchors + bounds these for you (raw \`[:CDG*]\`/\`[:REACHING_DEF*]\` path scans are unindexed and unbounded).
${HOT_READ_STALENESS_NOTE}`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
@ -307,7 +315,9 @@ REQUIRES RE-INDEX: causes.scopeExtractionFiles, causes.receiverTyping, causes.ex
GROUP MODE: set "repo" to "@<groupName>" to run context in each member repo (aggregated list), or "@<groupName>/<groupRepoPath>" for one member. If you use "@<groupName>" only, the member defaults to the lexicographically first key in group.yaml "repos".
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`,
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.
${HOT_READ_STALENESS_NOTE}`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
@ -521,7 +531,9 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first. Any short answer carries truncated:true, truncatedRepos, riskEpistemic:"lower-bound" AND a truncationReason — dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict. truncated:true does NOT always mean the fan-out ran out of room, so branch on truncationReason: the remedy differs. 'timeout' (the fan-out's wall-clock budget expired) and 'partial' (a neighbour crossing, or the local walk, was cut short) are runtime limits — the same query can return more on a retry or with a larger timeoutMs. 'incomplete-sync' is structural: the group bridge was built by a sync that could not say which repos it read, or that could not read an in-scope repo, so those repos' contracts are absent from EVERY query against this bridge, and truncatedRepos names them even when ZERO crossings to them were attempted. Retrying returns the same floor — run group_sync (\`gitnexus group sync\`) and query again. 'suppressed-stage' is also structural but has a DIFFERENT remedy: the sync was asked to skip a matching stage (\`--exact-only\` / exactOnly), so cross-links that stage would have found are absent BY REQUEST. Re-running the sync unchanged returns the same floor — re-run it WITHOUT that flag. Do not report a repo as broken for this reason; nothing failed to read.
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`,
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.
${HOT_READ_STALENESS_NOTE}`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',

View file

@ -27,13 +27,18 @@ export interface RepoProjectionSource {
* Staleness through the shared {@link stalenessPayload} builder, so this route
* and MCP `list_repos` emit one shape for one fact (#3232 review, #3256).
*
* Absent when the index is current. Otherwise `staleness.status` says what git
* could establish: `behind` with the counted `commitsBehind`; `diverged` when
* HEAD has provably moved off the indexed commit but the history needed to
* count the gap is gone — the state a branch-pinned `url` clone reaches once
* git prunes the commit a failed re-index left behind; or `unknown` when the
* repository could not be measured at all. This is a listing a monitor reads,
* so `unknown` is included here, unlike on the hot read tools.
* This listing/HTTP helper uses the no-ref form: absent when the index is
* current; `unknown` is included via `includeUnknown`. Otherwise
* `staleness.status` says what git could establish: `behind` with the counted
* `commitsBehind`; `diverged` when HEAD has provably moved off the indexed
* commit but the history needed to count the gap is gone — the state a
* branch-pinned `url` clone reaches once git prunes the commit a failed
* re-index left behind; or `unknown` when the repository could not be
* measured at all.
*
* Hot read tools (`query`/`context`/`impact`/`cypher`) use the ref-carrying
* form: they emit `unknown` (and `current`) with `branch?`/`lastCommit`/
* `indexedAt`/`measuredAgainst`.
*
* All of it measures the index against the local working tree — the same thing
* `gitnexus status` and MCP `list_repos` measure — not against the remote.

View file

@ -104,6 +104,18 @@ function normalizeContext(value: unknown): Record<string, unknown> {
};
}
/** Drop analyze-time `indexedAt` so incremental vs force surfaces can compare graph fields. */
function withoutVolatileStaleness(value: unknown): unknown {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
const record = value as Record<string, unknown>;
const staleness = record.staleness;
if (staleness === undefined || typeof staleness !== 'object' || staleness === null) {
return record;
}
const { indexedAt: _indexedAt, ...stableStaleness } = staleness as Record<string, unknown>;
return { ...record, staleness: stableStaleness };
}
async function readPersistedObjectiveCSurface(repoRoot: string): Promise<Record<string, unknown>> {
const backend = new LocalBackend();
try {
@ -186,10 +198,10 @@ async function readPersistedObjectiveCSurface(repoRoot: string): Promise<Record<
candidateEvidenceContext: normalizeContext(candidateEvidenceContext),
categoryContext: normalizeContext(categoryContext),
queryDefinitions: normalizeRows(queryResult.definitions),
protocolAndCategoryResult,
categoryHostResult,
protocolCandidateResult,
unresolvedReasonResult,
protocolAndCategoryResult: withoutVolatileStaleness(protocolAndCategoryResult),
categoryHostResult: withoutVolatileStaleness(categoryHostResult),
protocolCandidateResult: withoutVolatileStaleness(protocolCandidateResult),
unresolvedReasonResult: withoutVolatileStaleness(unresolvedReasonResult),
};
} finally {
await backend.disconnect();
@ -763,6 +775,25 @@ describe('Objective-C provider integration', () => {
});
describe('Objective-C provider persisted index behavior', () => {
it('drops analyze-time indexedAt from hot-tool staleness so increment vs force can compare', () => {
expect(
withoutVolatileStaleness({
markdown: 'ok',
row_count: 1,
staleness: {
status: 'current',
lastCommit: 'abc',
indexedAt: '2026-09-15T00:00:00Z',
measuredAgainst: 'HEAD',
},
}),
).toEqual({
markdown: 'ok',
row_count: 1,
staleness: { status: 'current', lastCommit: 'abc', measuredAgainst: 'HEAD' },
});
});
it('surfaces query/context semantics and keeps incremental results aligned with force rebuild', async () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-provider-index-'));
try {

View file

@ -297,6 +297,9 @@ describe('generateAIContextFiles', () => {
const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
expect(content).toContain('Index stale? Run `node .gitnexus/run.cjs analyze --index-only`');
expect(content).toContain(
'On query/context/impact/cypher object results, read staleness.status and branch/lastCommit',
);
expect(content).toContain('## Always Do');
expect(content).toContain('## Never Do');
expect(content).toContain('## Resources');
@ -337,15 +340,18 @@ describe('generateAIContextFiles', () => {
// clause (previously hand-added inside the committed docs instead of this
// template, so a real `gitnexus analyze` silently deleted them on every
// regeneration — moving them into the template is the fix, and they are
// unconditional text load-bearing enough to warrant the budget) — each time
// with the same argument, that the added line is load-bearing and the block
// is still meaningfully smaller than the original. That is a ratchet with no
// ratchet: an absolute cap can only ever fail on the PR that adds the
// character, and the fix is always to nudge the number. Assert the invariant
// the justifications actually appeal to — the RATIO to the pre-trim size —
// so a legitimate clause fits without ceremony while a genuine re-pad fails.
// (The structural guard is the sibling test asserting the six #856 section
// headers stay deleted; this one bounds bulk.)
// unconditional text load-bearing enough to warrant the budget), then
// 0.65 → 0.70 for the #3291 always-on `staleness` blockquote line (a second
// `>` line, not a new Always-Do bullet — the cheaper durable slot; the
// 0.65 band had ~3 chars of headroom so any useful line required a bump) —
// each time with the same argument, that the added line is load-bearing and
// the block is still meaningfully smaller than the original. That is a
// ratchet with no ratchet: an absolute cap can only ever fail on the PR
// that adds the character, and the fix is always to nudge the number.
// Assert the invariant the justifications actually appeal to — the RATIO
// to the pre-trim size — so a legitimate clause fits without ceremony
// while a genuine re-pad fails. (The structural guard is the sibling test
// asserting the six #856 section headers stay deleted; this one bounds bulk.)
const PRE_TRIM_BLOCK_CHARS = 5465;
const stats = { nodes: 50, edges: 100, processes: 5 };
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
@ -355,7 +361,7 @@ describe('generateAIContextFiles', () => {
content.indexOf('<!-- gitnexus:start -->'),
content.indexOf('<!-- gitnexus:end -->'),
);
expect(block.length).toBeLessThan(PRE_TRIM_BLOCK_CHARS * 0.65);
expect(block.length).toBeLessThan(PRE_TRIM_BLOCK_CHARS * 0.7);
});
it('handles empty stats', async () => {

View file

@ -441,7 +441,10 @@ describe('LocalBackend.callTool', () => {
direction: 'upstream',
});
expect(result).toEqual({ status: 'normalized' });
// toMatchObject, not toEqual: since #3291 every hot-read-tool response
// also carries the `staleness` ref field. This test is about parameter
// normalization, so it pins the payload it cares about and ignores it.
expect(result).toMatchObject({ status: 'normalized' });
const dispatched = impactSpy.mock.calls[0][1] as Record<string, unknown>;
expect(dispatched.target).toBe('validate');
expect(dispatched).not.toHaveProperty('name');
@ -459,7 +462,8 @@ describe('LocalBackend.callTool', () => {
file: ' src/auth.ts ',
});
expect(result).toEqual({ status: 'normalized' });
// toMatchObject: responses carry the #3291 `staleness` ref field too.
expect(result).toMatchObject({ status: 'normalized' });
const dispatched = contextSpy.mock.calls[0][1] as Record<string, unknown>;
expect(dispatched.file_path).toBe('src/auth.ts');
expect(dispatched).not.toHaveProperty('file');
@ -476,7 +480,8 @@ describe('LocalBackend.callTool', () => {
file: undefined,
});
expect(result).toEqual({ status: 'normalized' });
// toMatchObject: responses carry the #3291 `staleness` ref field too.
expect(result).toMatchObject({ status: 'normalized' });
expect(contextSpy.mock.calls[0][1]).toMatchObject({ name: 'validate' });
});
@ -5040,6 +5045,28 @@ describe('LocalBackend.resolveRepo branch scope (#2106)', () => {
expect(path.basename(handle.lbugPath)).toBe('lbug');
// The branch handle reports the branch's own commit, not the primary's.
expect(handle.lastCommit).toBe('featsha');
// #3291: the pin's label, not the flat/primary slot — withToolStaleness
// copies handle.branch onto the hot-tool payload.
expect(handle.branch).toBe('feature/x');
});
it('a pinned-branch tool result names the pin in staleness.branch (#3291)', async () => {
// beforeEach clearAllMocks() drops the module-level git-staleness factory
// impl; restore a resolving current so withToolStaleness attaches the ref.
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
(checkStalenessAsync as any).mockResolvedValue({
isStale: false,
commitsBehind: 0,
status: 'current',
});
vi.spyOn(backend as any, 'impact').mockResolvedValue({ ok: true });
const result = (await backend.callTool('impact', {
target: 'doWork',
repo: 'multi',
branch: 'feature/x',
})) as { staleness: { branch?: string; lastCommit?: string } };
expect(result.staleness.branch).toBe('feature/x');
expect(result.staleness.lastCommit).toBe('featsha');
});
it('an un-indexed branch throws a clear error', async () => {
@ -5295,11 +5322,20 @@ describe('LocalBackend tool-staleness cache keying (#2655 review)', () => {
branch: 'x',
});
// Flat index (lastCommit=FLATSHA) is 5 behind -> field present.
expect(flatRes).toMatchObject({ staleness: { commitsBehind: 5 } });
// Branch index (different lbugPath + lastCommit) is current; it must NOT
// inherit the flat handle's cached staleness (the pre-fix repoPath-keyed bug).
expect(branchRes).not.toHaveProperty('staleness');
// Flat index (lastCommit=FLATSHA) is 5 behind -> counted gap reported.
expect(flatRes).toMatchObject({
staleness: { status: 'behind', commitsBehind: 5, lastCommit: 'FLATSHA' },
});
// Branch index (different lbugPath + lastCommit) is current. Since #3291 the
// field rides on every response, so absence can no longer be the proof; what
// shows the flat handle's cached entry was NOT reused (the pre-fix
// repoPath-keyed bug) is that this one reports its OWN commit and its own
// status, with no trace of the flat handle's counted gap.
expect(branchRes).toMatchObject({
staleness: { status: 'current', lastCommit: 'BRANCHSHA' },
});
const branchStaleness = (branchRes as { staleness: { commitsBehind?: number } }).staleness;
expect(branchStaleness.commitsBehind).toBeUndefined();
});
});

View file

@ -0,0 +1,263 @@
/**
* #3291: the tool staleness payload names the ref it describes, so an `impact`
* answer computed from a branch-pinned index is distinguishable from one
* computed from a current index of the default branch.
*
* Unlike the rest of the staleness suite, this file deliberately does NOT mock
* `core/git-staleness.js`. Real `git rev-list` runs against two real clones —
* that is the whole point: the behaviour is a composition of the real
* measurement (`<lastCommit>..HEAD`, against the clone's own checkout) with
* what `stalenessPayload` does about it, and stubbing the measurement would
* assume the step under test.
*
* Before the fix both responses carried no `staleness` field at all and were
* byte-identical; the serialized-inequality assertion below is the one that
* could not discriminate them.
*/
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const { lbugMocks } = vi.hoisted(() => ({
lbugMocks: {
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
executeParameterized: vi.fn().mockResolvedValue([]),
ensureVectorExtension: vi.fn().mockResolvedValue(true),
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
}));
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({
...(await importOriginal<object>()),
...lbugMocks,
}));
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => ({
...(await importOriginal<object>()),
...lbugMocks,
}));
// `readRegistry` is pinned to empty so the real `checkCwdMatch` (reached through
// the un-mocked git-staleness module) cannot read the developer's own registry.
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/storage/repo-manager.js')>()),
listRegisteredRepos: vi.fn().mockResolvedValue([]),
readRegistry: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }),
}));
vi.mock('../../src/mcp/core/embedder.js', () => ({
embedQuery: vi.fn().mockResolvedValue([]),
getEmbeddingDims: vi.fn().mockReturnValue(384),
}));
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
const git = (cwd: string, ...args: string[]): string =>
execFileSync('git', args, {
cwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
}).trim();
const commit = (repo: string, file: string, body: string): void => {
writeFileSync(path.join(repo, file), body);
git(repo, 'add', '-A');
git(repo, 'commit', '-m', `add ${file}`);
};
const fixtureRoots: string[] = [];
/**
* A source repo with `main` three commits deep and `feature/x` branched off the
* first, then two clones of it: one on `main`, one pinned to `feature/x` — the
* separate-clone-per-pinned-branch layout #3199 made routine.
*/
function makeClones(): { mainClone: string; branchClone: string } {
const root = mkdtempSync(path.join(os.tmpdir(), 'gnx-3291-'));
fixtureRoots.push(root);
const source = path.join(root, 'source');
mkdirSync(source);
git(source, 'init', '-b', 'main');
git(source, 'config', 'user.email', 'repro@3291.test');
git(source, 'config', 'user.name', 'Repro 3291');
commit(source, 'a.ts', 'export const a = 1;\n');
git(source, 'checkout', '-b', 'feature/x');
commit(source, 'feature.ts', 'export const f = 1;\n');
git(source, 'checkout', 'main');
commit(source, 'b.ts', 'export const b = 2;\n');
commit(source, 'c.ts', 'export const c = 3;\n');
const mainClone = path.join(root, 'clone-main');
const branchClone = path.join(root, 'clone-feature');
git(root, 'clone', '--branch', 'main', source, mainClone);
git(root, 'clone', '--branch', 'feature/x', source, branchClone);
return { mainClone, branchClone };
}
interface ToolStaleness {
status: string;
branch?: string;
lastCommit?: string;
indexedAt?: string;
measuredAgainst?: string;
commitsBehind?: number;
hint?: string;
}
const INDEXED_AT = '2026-09-15T00:00:00Z';
const IMPACT_RESULT = { target: 'doWork', impactedCount: 0, risk: 'LOW' };
const handleFor = (repoPath: string, lastCommit: string, branch: string) => ({
id: branch,
name: 'repro-3291',
repoPath,
storagePath: path.join(repoPath, '.gitnexus'),
lbugPath: path.join(repoPath, '.gitnexus', branch, 'lbug'),
indexedAt: INDEXED_AT,
lastCommit,
branch,
});
afterAll(() => {
for (const dir of fixtureRoots) rmSync(dir, { recursive: true, force: true });
});
describe('#3291 — tool staleness names the indexed ref', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
await backend.init();
});
it('distinguishes a behind-main feature-branch index from a current main index', async () => {
const { mainClone, branchClone } = makeClones();
const mainHead = git(mainClone, 'rev-parse', 'HEAD');
const branchHead = git(branchClone, 'rev-parse', 'HEAD');
// Precondition, measured rather than assumed: the feature-branch clone is
// freshly analyzed at its own head, and that head is genuinely two commits
// short of the mainline. Without this the test would pass vacuously.
const behindMain = Number(
git(branchClone, 'rev-list', '--count', `${branchHead}..origin/main`),
);
expect(behindMain).toBe(2);
const resolve = vi.spyOn(backend, 'selectToolRepository');
resolve.mockResolvedValueOnce(handleFor(mainClone, mainHead, 'main') as never);
resolve.mockResolvedValueOnce(handleFor(branchClone, branchHead, 'feature/x') as never);
// The graph answer itself is held constant so the only thing that can differ
// between the two responses is the freshness signalling under test.
vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue(
IMPACT_RESULT as never,
);
const fromMain = (await backend.callTool('impact', {
target: 'doWork',
repo: 'repro-3291',
})) as { staleness: ToolStaleness };
const fromBranch = (await backend.callTool('impact', {
target: 'doWork',
repo: 'repro-3291',
branch: 'feature/x',
})) as { staleness: ToolStaleness };
// Non-vacuity: both calls really produced the graph answer. An error
// envelope or a non-object would be skipped by `canCarryStaleness` and the
// assertions below would hold for the wrong reason.
expect(fromMain).toMatchObject(IMPACT_RESULT);
expect(fromBranch).toMatchObject(IMPACT_RESULT);
// Each index measures 0 commits behind its OWN checkout, so both are
// `current` — but each now says which ref that statement is about.
expect(fromBranch.staleness).toEqual({
status: 'current',
branch: 'feature/x',
lastCommit: branchHead,
indexedAt: INDEXED_AT,
measuredAgainst: 'HEAD',
});
expect(fromMain.staleness).toMatchObject({
status: 'current',
branch: 'main',
lastCommit: mainHead,
});
// The regression itself: before the fix these two were byte-identical, so an
// answer two commits short of the mainline read exactly like a current one.
expect(JSON.stringify(fromBranch)).not.toBe(JSON.stringify(fromMain));
// And the ref is recoverable from the response alone, with no second call.
const serialized = JSON.stringify(fromBranch);
expect(serialized).toContain('feature/x');
expect(serialized).toContain(branchHead);
});
// Negative control. The same wiring — real git, real `checkStalenessAsync`,
// real `attachToolStaleness` — must still report a counted gap when the index
// is behind its own checkout. Without this, the ref-carrying payload above
// could be masking a freshness signal that no longer works.
it('still reports the counted gap when the index is behind its own checkout', async () => {
const { mainClone } = makeClones();
const twoBack = git(mainClone, 'rev-parse', 'HEAD~2');
vi.spyOn(backend, 'selectToolRepository').mockResolvedValue(
handleFor(mainClone, twoBack, 'main') as never,
);
vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue(
IMPACT_RESULT as never,
);
const result = (await backend.callTool('impact', {
target: 'doWork',
repo: 'repro-3291',
})) as { staleness: ToolStaleness };
expect(result.staleness).toMatchObject({
status: 'behind',
commitsBehind: 2,
branch: 'main',
lastCommit: twoBack,
measuredAgainst: 'HEAD',
});
});
// A detached HEAD or a legacy index records no branch label, so the ref has to
// survive without one — `lastCommit` is the identifier that is always present.
it('names the ref by commit when the index records no branch label', async () => {
const { mainClone } = makeClones();
const head = git(mainClone, 'rev-parse', 'HEAD');
const { branch: _unlabelled, ...unlabelledHandle } = handleFor(mainClone, head, 'main');
vi.spyOn(backend, 'selectToolRepository').mockResolvedValue(unlabelledHandle as never);
vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue(
IMPACT_RESULT as never,
);
const result = (await backend.callTool('impact', {
target: 'doWork',
repo: 'repro-3291',
})) as { staleness: ToolStaleness };
expect(result.staleness).toEqual({
status: 'current',
lastCommit: head,
indexedAt: INDEXED_AT,
measuredAgainst: 'HEAD',
});
});
});

View file

@ -249,26 +249,25 @@ describe('intended standard-skill improvements stay in every applicable copy', (
}
});
// #2899: the "Inline staleness signal" section was deleted from the
// canonical `.claude/` copy by an unrelated commit while the plugin mirror
// kept it — the same silent-deletion shape as the UNKNOWN-risk guard above,
// just for a hand-authored section instead of the machine-managed block.
// Scoped to canonical + plugin only: at the time of writing the npm mirror
// (gitnexus/skills/gitnexus-guide.md) already lacks this section as
// pre-existing, unrelated drift, so folding it into the loop above would
// fail on that unrelated copy instead of guarding this regression.
it('keeps the inline-staleness-signal section in the canonical and plugin guide copies', () => {
for (const file of [
path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus-guide', 'SKILL.md'),
path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills', 'gitnexus-guide', 'SKILL.md'),
]) {
// #2899 / #3291: every gitnexus-guide distribution documents the with-ref
// hot-tool field. A copy that drops the section (or stays on the pre-#3291
// "absent when current" contract) ships a silent disagreement about identity.
it('keeps the inline-staleness-signal section in every gitnexus-guide copy', () => {
for (const file of standardSkillCopies('gitnexus-guide')) {
const content = fs.readFileSync(file, 'utf-8');
expect(content).toContain('### Inline staleness signal');
expect(content).toContain('commitsBehind');
// #3256: the field gained `status`, and the `diverged` arm carries no
// count — the reason an agent has to read `status` before the number.
expect(content).toContain('{ status, commitsBehind?, hint? }');
// #3291: it also gained the indexed ref, and is now emitted for every
// status rather than suppressed when the index is current — without the
// ref, `current` cannot distinguish an index of the default branch from
// one of a feature branch.
expect(content).toContain(
'{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }',
);
expect(content).toContain('"status": "diverged"');
expect(content).toContain('"measuredAgainst": "HEAD"');
}
});
@ -300,6 +299,8 @@ describe('intended standard-skill improvements stay in every applicable copy', (
'repo: "my-app"',
'bind repo; explicit repo when >1 indexed, ask if ambiguous',
'Re-analyze only for `behind` or `diverged`',
];
const copies = standardSkillCopies(name);
expect(copies.length).toBeGreaterThan(1);

View file

@ -9,6 +9,7 @@
*/
import { describe, it, expect } from 'vitest';
import type { StalenessInfo } from '../../src/core/git-staleness.js';
import { stalenessPayload, type IndexedRef } from '../../src/core/staleness-status.js';
import { attachToolStaleness } from '../../src/mcp/local/local-backend.js';
const STALE: StalenessInfo = {
@ -103,3 +104,115 @@ describe('attachToolStaleness — status (#3256)', () => {
).toBe(result);
});
});
// ── #3291: the ref-carrying form ─────────────────────────────────────────────
//
// `stalenessPayload` is the single builder behind three surfaces, so the fix has
// to add a shape without disturbing the one already in use. These pin both
// halves: WITHOUT a ref the output is bit-identical to the pre-#3291 shape for
// every status — which is what keeps `list_repos` and the `serve` repo routes
// byte-stable and their exact-match tests passing unmodified — and WITH one it
// names the index it describes, including when that index is `current`.
const REF: IndexedRef = {
branch: 'feature/x',
lastCommit: 'f'.repeat(40),
indexedAt: '2026-09-15T00:00:00Z',
};
const CURRENT: StalenessInfo = { isStale: false, commitsBehind: 0, status: 'current' };
const UNKNOWN: StalenessInfo = { isStale: false, commitsBehind: 0, status: 'unknown' };
const DIVERGED: StalenessInfo = {
isStale: false,
commitsBehind: 0,
hint: 'moved on',
status: 'diverged',
};
const BEHIND: StalenessInfo = {
isStale: true,
commitsBehind: 3,
hint: '3 behind',
status: 'behind',
};
describe('stalenessPayload without a ref — unchanged by #3291', () => {
it('omits the payload entirely for a current index', () => {
expect(stalenessPayload(CURRENT)).toBeUndefined();
});
it('omits unknown unless the caller asks, and emits it bare when it does', () => {
expect(stalenessPayload(UNKNOWN)).toBeUndefined();
expect(stalenessPayload(UNKNOWN, { includeUnknown: true })).toEqual({ status: 'unknown' });
});
it('reports diverged with its hint and no invented count', () => {
expect(stalenessPayload(DIVERGED)).toEqual({ status: 'diverged', hint: 'moved on' });
});
it('reports behind with the counted gap', () => {
expect(stalenessPayload(BEHIND)).toEqual({
status: 'behind',
commitsBehind: 3,
hint: '3 behind',
});
});
});
describe('stalenessPayload with a ref (#3291)', () => {
it('emits a current index instead of suppressing it, naming the ref', () => {
// The regression #3291 reported: `current` alone cannot distinguish an index
// of the default branch from one of a feature branch.
expect(stalenessPayload(CURRENT, { ref: REF })).toEqual({
status: 'current',
branch: 'feature/x',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
});
});
it('names the ref on unknown too — which index answered is knowable when its freshness is not', () => {
expect(stalenessPayload(UNKNOWN, { ref: REF })).toEqual({
status: 'unknown',
branch: 'feature/x',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
});
});
it('keeps diverged free of an invented count while carrying the ref', () => {
const out = stalenessPayload(DIVERGED, { ref: REF });
expect(out).toEqual({
status: 'diverged',
branch: 'feature/x',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
hint: 'moved on',
});
expect('commitsBehind' in (out ?? {})).toBe(false);
});
it('carries the counted gap alongside the ref', () => {
expect(stalenessPayload(BEHIND, { ref: REF })).toEqual({
status: 'behind',
branch: 'feature/x',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
commitsBehind: 3,
hint: '3 behind',
});
});
it('omits branch for a detached HEAD or legacy index, keeping lastCommit as the identifier', () => {
const { branch: _unlabelled, ...noBranch } = REF;
expect(stalenessPayload(CURRENT, { ref: noBranch })).toEqual({
status: 'current',
lastCommit: REF.lastCommit,
indexedAt: REF.indexedAt,
measuredAgainst: 'HEAD',
});
});
});

View file

@ -116,6 +116,14 @@ describe('GITNEXUS_TOOLS', () => {
}
});
it('query, context, impact, and cypher descriptions mention always-on staleness (#3291)', () => {
for (const name of ['query', 'context', 'impact', 'cypher'] as const) {
const tool = GITNEXUS_TOOLS.find((t) => t.name === name)!;
expect(tool.description).toContain('staleness');
expect(tool.description).toMatch(/lastCommit|branch/);
}
});
it('query tool requires "search_query" parameter (renamed from "query" for #2175)', () => {
const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!;
expect(queryTool.inputSchema.required).toContain('search_query');