gitnexus 1.6.13-rc.5 → 1.6.13-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -137,6 +137,7 @@ ${tableBody}`
137
137
  This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}.
138
138
 
139
139
  > Index stale? Run \`${runner} analyze --index-only\` from the project root — it auto-selects an available runner. ${bootstrapNote}
140
+ > 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.
140
141
 
141
142
  ## Always Do
142
143
 
@@ -47,17 +47,51 @@ export interface StalenessInfo {
47
47
  }
48
48
  /** `info.status`, or the answer `isStale` implies for an info built without one. */
49
49
  export declare const stalenessStatus: (info: StalenessInfo) => StalenessStatus;
50
+ /**
51
+ * The ref an index represents, as the resolved repo handle already knows it.
52
+ * `lastCommit` and `indexedAt` are always recorded on a handle; `branch` is
53
+ * best-effort — a plain analyze stamps the checked-out branch, but a detached
54
+ * HEAD, a non-git folder, or a legacy index that never recorded one leaves it
55
+ * absent (`run-analyze.ts`: `branchLabel ?? existingMeta?.branch`).
56
+ */
57
+ export interface IndexedRef {
58
+ branch?: string;
59
+ lastCommit: string;
60
+ indexedAt: string;
61
+ }
50
62
  /**
51
63
  * The wire shape for staleness on every surface: MCP `list_repos`, the hot read
52
64
  * tools, and the `serve` repo routes. One builder so one fact has one shape
53
65
  * (#3232 review: "same sentinel as MCP").
54
66
  *
55
- * Absent for `current`, as before. `commitsBehind` is present only when git
56
- * actually counted it, so `diverged` carries `status` and `hint` but no number —
57
- * inventing one would be the silent wrong answer this exists to remove.
67
+ * Two forms, chosen by whether the caller supplies a {@link IndexedRef}:
68
+ *
69
+ * - **Without a ref** — absent for `current`, as before. That is what
70
+ * `list_repos` and the `serve` routes emit; they already report the ref
71
+ * through their own top-level `branch` / `lastCommit` / `indexedAt` fields
72
+ * (#3226), so repeating it inside the payload would duplicate it.
73
+ * - **With a ref** — emitted for EVERY status, naming the index it describes.
74
+ * The hot read tools have nowhere else to put it: `attachToolStaleness` may
75
+ * add exactly one key to an arbitrary tool result. Without it `current` is
76
+ * indistinguishable between an index of the default branch and one of some
77
+ * feature branch, because `current` is a statement about a *ref*, not about
78
+ * the repository (#3291).
79
+ *
80
+ * `commitsBehind` is present only when git actually counted it, so `diverged`
81
+ * carries `status` and `hint` but no number — inventing one would be the silent
82
+ * wrong answer this exists to remove.
58
83
  */
59
84
  export interface StalenessPayload {
60
- status: Exclude<StalenessStatus, 'current'>;
85
+ status: StalenessStatus;
86
+ /** Ref identity — present only on the ref-carrying (hot read tool) form. */
87
+ branch?: string;
88
+ lastCommit?: string;
89
+ indexedAt?: string;
90
+ /**
91
+ * What `commitsBehind` is counted against: the checked-out HEAD of the clone
92
+ * this index was built from, never the remote or the default branch.
93
+ */
94
+ measuredAgainst?: 'HEAD';
61
95
  commitsBehind?: number;
62
96
  hint?: string;
63
97
  }
@@ -66,9 +100,12 @@ export interface StalenessPayload {
66
100
  * nothing to report.
67
101
  *
68
102
  * `unknown` is emitted only when `includeUnknown` is set. A listing a monitor
69
- * reads wants it; the hot read tools do not, because a `--skip-git` folder has
70
- * no history to measure and would otherwise repeat that on every response.
103
+ * reads wants it; the no-ref hot-tool form does not, because a `--skip-git`
104
+ * folder has no history to measure and would otherwise repeat that on every
105
+ * response. The ref-carrying form reports it regardless — which index answered
106
+ * is knowable even when its freshness is not.
71
107
  */
72
108
  export declare const stalenessPayload: (info: StalenessInfo | undefined, opts?: {
73
109
  includeUnknown?: boolean;
110
+ ref?: IndexedRef;
74
111
  }) => StalenessPayload | undefined;
@@ -14,19 +14,37 @@ export const stalenessStatus = (info) => info.status ?? (info.isStale ? 'behind'
14
14
  * nothing to report.
15
15
  *
16
16
  * `unknown` is emitted only when `includeUnknown` is set. A listing a monitor
17
- * reads wants it; the hot read tools do not, because a `--skip-git` folder has
18
- * no history to measure and would otherwise repeat that on every response.
17
+ * reads wants it; the no-ref hot-tool form does not, because a `--skip-git`
18
+ * folder has no history to measure and would otherwise repeat that on every
19
+ * response. The ref-carrying form reports it regardless — which index answered
20
+ * is knowable even when its freshness is not.
19
21
  */
20
22
  export const stalenessPayload = (info, opts = {}) => {
21
23
  if (!info)
22
24
  return undefined;
23
25
  const status = stalenessStatus(info);
24
26
  const hint = info.hint ? { hint: info.hint } : {};
25
- if (status === 'current')
26
- return undefined;
27
- if (status === 'unknown')
28
- return opts.includeUnknown ? { status } : undefined;
27
+ // No ref: bit-identical to the pre-#3291 output for every status. This is
28
+ // what keeps `list_repos` and both `serve` routes byte-stable, and their
29
+ // exact-match tests passing unmodified.
30
+ if (!opts.ref) {
31
+ if (status === 'current')
32
+ return undefined;
33
+ if (status === 'unknown')
34
+ return opts.includeUnknown ? { status } : undefined;
35
+ if (status === 'diverged')
36
+ return { status, ...hint };
37
+ return { status, commitsBehind: info.commitsBehind, ...hint };
38
+ }
39
+ const ref = {
40
+ ...(opts.ref.branch ? { branch: opts.ref.branch } : {}),
41
+ lastCommit: opts.ref.lastCommit,
42
+ indexedAt: opts.ref.indexedAt,
43
+ measuredAgainst: 'HEAD',
44
+ };
45
+ if (status === 'current' || status === 'unknown')
46
+ return { status, ...ref };
29
47
  if (status === 'diverged')
30
- return { status, ...hint };
31
- return { status, commitsBehind: info.commitsBehind, ...hint };
48
+ return { status, ...ref, ...hint };
49
+ return { status, ...ref, commitsBehind: info.commitsBehind, ...hint };
32
50
  };
@@ -8,7 +8,7 @@
8
8
  import { isTestFilePath } from '../../core/ingestion/utils/test-file-path.js';
9
9
  import { type RegistryEntry, type BranchSummary } from '../../storage/repo-manager.js';
10
10
  import { GroupService } from '../../core/group/service.js';
11
- import { type StalenessInfo, type StalenessPayload } from '../../core/staleness-status.js';
11
+ import { type IndexedRef, type StalenessInfo, type StalenessPayload } from '../../core/staleness-status.js';
12
12
  /** Shared predicate; re-exported so MCP importers keep the old public name. */
13
13
  export { isTestFilePath };
14
14
  /** Valid LadybugDB node labels for safe Cypher query construction */
@@ -186,7 +186,11 @@ interface RepoHandle {
186
186
  lastCommit: string;
187
187
  remoteUrl?: string;
188
188
  stats?: RegistryEntry['stats'];
189
- /** Primary/flat branch name, when known (#2106). */
189
+ /**
190
+ * Branch this handle's index describes (#2106/#3291). The flat/workspace
191
+ * slot keeps the primary checkout name; `applyBranchScope` overwrites it
192
+ * with the pin when serving a `branches[]` sub-index.
193
+ */
190
194
  branch?: string;
191
195
  /** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
192
196
  branches?: BranchSummary[];
@@ -263,17 +267,24 @@ export interface ListReposPagination {
263
267
  nextOffset?: number;
264
268
  }
265
269
  /**
266
- * #2655: attach a non-blocking `staleness` signal to a tool result when the
267
- * index is not at HEAD, in the same {@link stalenessPayload} shape `list_repos`
268
- * returns. Only ever ADDS a field to a carryable object result (see
269
- * {@link canCarryStaleness}) — it never changes an existing result's shape.
270
+ * #2655: attach a non-blocking `staleness` signal to a tool result. Only ever
271
+ * ADDS a field to a carryable object result (see {@link canCarryStaleness}) —
272
+ * it never changes an existing result's shape.
270
273
  *
271
- * `diverged` is attached: it is a positive finding that the index is not at
272
- * HEAD, only uncountable. `unknown` is not — these are the hot read tools, and a
273
- * `--skip-git` folder has no history to measure, so it would ride on every
274
- * response as noise rather than signal (#3256).
274
+ * #3291: `ref` names the index the answer came from. Supplying it switches the
275
+ * payload to the ref-carrying form, which reports every status — including
276
+ * `current` and `unknown` — because that one added key is the only place a tool
277
+ * result can say WHICH index answered. Absence used to be the freshness signal
278
+ * here; it could not distinguish a current index of the default branch from a
279
+ * current index of some feature branch, since `current` is a statement about a
280
+ * ref rather than about the repository.
281
+ *
282
+ * With no `ref` the pre-#3291 behaviour is unchanged: absent for `current`,
283
+ * `diverged` attached as a positive finding that the index is not at HEAD, and
284
+ * `unknown` withheld as noise (#3256). A missing `info` still attaches nothing
285
+ * either way, which is what keeps a failed freshness probe non-fatal.
275
286
  */
276
- export declare function attachToolStaleness(result: unknown, info: StalenessInfo | undefined): unknown;
287
+ export declare function attachToolStaleness(result: unknown, info: StalenessInfo | undefined, ref?: IndexedRef): unknown;
277
288
  export declare class LocalBackend {
278
289
  private static readonly TOOL_STALENESS_TTL_MS;
279
290
  private repos;
@@ -567,6 +578,12 @@ export declare class LocalBackend {
567
578
  * skipping the `git` spawn entirely for results that can't carry it (error
568
579
  * envelopes, arrays, non-objects — see {@link canCarryStaleness}) so an
569
580
  * error-returning call pays nothing.
581
+ *
582
+ * #3291: the ref comes straight off the already-resolved handle, so naming
583
+ * the index costs no extra I/O — no git spawn, no metadata read, and the
584
+ * `stalenessForTool` TTL cache is untouched. `branch` is passed through as-is
585
+ * and is legitimately absent for a detached HEAD or a legacy index; the
586
+ * always-present `lastCommit` is what identifies the index in that case.
570
587
  */
571
588
  private withToolStaleness;
572
589
  /**
@@ -925,18 +925,25 @@ function canCarryStaleness(result) {
925
925
  !('staleness' in result));
926
926
  }
927
927
  /**
928
- * #2655: attach a non-blocking `staleness` signal to a tool result when the
929
- * index is not at HEAD, in the same {@link stalenessPayload} shape `list_repos`
930
- * returns. Only ever ADDS a field to a carryable object result (see
931
- * {@link canCarryStaleness}) — it never changes an existing result's shape.
928
+ * #2655: attach a non-blocking `staleness` signal to a tool result. Only ever
929
+ * ADDS a field to a carryable object result (see {@link canCarryStaleness}) —
930
+ * it never changes an existing result's shape.
932
931
  *
933
- * `diverged` is attached: it is a positive finding that the index is not at
934
- * HEAD, only uncountable. `unknown` is not — these are the hot read tools, and a
935
- * `--skip-git` folder has no history to measure, so it would ride on every
936
- * response as noise rather than signal (#3256).
932
+ * #3291: `ref` names the index the answer came from. Supplying it switches the
933
+ * payload to the ref-carrying form, which reports every status — including
934
+ * `current` and `unknown` — because that one added key is the only place a tool
935
+ * result can say WHICH index answered. Absence used to be the freshness signal
936
+ * here; it could not distinguish a current index of the default branch from a
937
+ * current index of some feature branch, since `current` is a statement about a
938
+ * ref rather than about the repository.
939
+ *
940
+ * With no `ref` the pre-#3291 behaviour is unchanged: absent for `current`,
941
+ * `diverged` attached as a positive finding that the index is not at HEAD, and
942
+ * `unknown` withheld as noise (#3256). A missing `info` still attaches nothing
943
+ * either way, which is what keeps a failed freshness probe non-fatal.
937
944
  */
938
- export function attachToolStaleness(result, info) {
939
- const staleness = stalenessPayload(info);
945
+ export function attachToolStaleness(result, info, ref) {
946
+ const staleness = stalenessPayload(info, ref ? { ref } : {});
940
947
  if (!staleness || !canCarryStaleness(result)) {
941
948
  return result;
942
949
  }
@@ -1513,6 +1520,8 @@ export class LocalBackend {
1513
1520
  indexedAt: summary.indexedAt,
1514
1521
  lastCommit: summary.lastCommit,
1515
1522
  stats: summary.stats,
1523
+ // The handle now represents the pin, not the flat slot (#3291).
1524
+ branch: summary.branch,
1516
1525
  };
1517
1526
  }
1518
1527
  // Stale summary (sub-index adopted/deleted): refresh so later calls see
@@ -1994,6 +2003,12 @@ export class LocalBackend {
1994
2003
  * skipping the `git` spawn entirely for results that can't carry it (error
1995
2004
  * envelopes, arrays, non-objects — see {@link canCarryStaleness}) so an
1996
2005
  * error-returning call pays nothing.
2006
+ *
2007
+ * #3291: the ref comes straight off the already-resolved handle, so naming
2008
+ * the index costs no extra I/O — no git spawn, no metadata read, and the
2009
+ * `stalenessForTool` TTL cache is untouched. `branch` is passed through as-is
2010
+ * and is legitimately absent for a detached HEAD or a legacy index; the
2011
+ * always-present `lastCommit` is what identifies the index in that case.
1997
2012
  */
1998
2013
  async withToolStaleness(repo, result) {
1999
2014
  if (!canCarryStaleness(result))
@@ -2001,9 +2016,15 @@ export class LocalBackend {
2001
2016
  // Defensive: `checkStalenessAsync` self-catches today, but a rejection here
2002
2017
  // must never fail the tool — degrade to no-staleness. Paired with the
2003
2018
  // evict-on-reject in `stalenessForTool`, a transient failure also can't
2004
- // poison the TTL cache entry (#2655 review F1).
2019
+ // poison the TTL cache entry (#2655 review F1). A rejection leaves `info`
2020
+ // undefined, and the builder returns nothing for that even with a ref, so
2021
+ // the degraded path still attaches no field.
2005
2022
  const staleness = await this.stalenessForTool(repo).catch(() => undefined);
2006
- return attachToolStaleness(result, staleness);
2023
+ return attachToolStaleness(result, staleness, {
2024
+ branch: repo.branch,
2025
+ lastCommit: repo.lastCommit,
2026
+ indexedAt: repo.indexedAt,
2027
+ });
2007
2028
  }
2008
2029
  /**
2009
2030
  * #2655: commits-behind freshness for the hot read tools, deduped per index.
package/dist/mcp/tools.js CHANGED
@@ -51,6 +51,8 @@ export const PDG_QUERY_MAX_LIMIT = 200;
51
51
  export const IMPACT_MAX_DEPTH = 32;
52
52
  const CWD_AWARE_REPO_OMISSION = 'Omit when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing an unindexed nested Git checkout; otherwise specify it explicitly.';
53
53
  const MUTATING_REPO_OMISSION = 'Omit only when one repo is indexed or an MCP default is configured; otherwise mutating tools require an explicit repo.';
54
+ /** Always-on identity+freshness field on query/context/impact/cypher object results (#3291). */
55
+ 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).";
54
56
  export const GITNEXUS_TOOLS = [
55
57
  {
56
58
  name: 'list_repos',
@@ -105,7 +107,9 @@ Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank
105
107
 
106
108
  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).
107
109
 
108
- 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.`,
110
+ 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.
111
+
112
+ ${HOT_READ_STALENESS_NOTE}`,
109
113
  annotations: QUERY_TOOL_ANNOTATIONS,
110
114
  inputSchema: {
111
115
  type: 'object',
@@ -212,7 +216,9 @@ TIPS:
212
216
  - Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy
213
217
  - Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId
214
218
  - Use heuristicLabel (not label) for human-readable community/process names
215
- - 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).`,
219
+ - 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).
220
+
221
+ ${HOT_READ_STALENESS_NOTE}`,
216
222
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
217
223
  inputSchema: {
218
224
  type: 'object',
@@ -264,7 +270,9 @@ REQUIRES RE-INDEX: causes.scopeExtractionFiles, causes.receiverTyping, causes.ex
264
270
 
265
271
  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".
266
272
 
267
- 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.`,
273
+ 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.
274
+
275
+ ${HOT_READ_STALENESS_NOTE}`,
268
276
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
269
277
  inputSchema: {
270
278
  type: 'object',
@@ -473,7 +481,9 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match
473
481
 
474
482
  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.
475
483
 
476
- 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.`,
484
+ 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.
485
+
486
+ ${HOT_READ_STALENESS_NOTE}`,
477
487
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
478
488
  inputSchema: {
479
489
  type: 'object',
@@ -21,13 +21,18 @@ export interface RepoProjectionSource {
21
21
  * Staleness through the shared {@link stalenessPayload} builder, so this route
22
22
  * and MCP `list_repos` emit one shape for one fact (#3232 review, #3256).
23
23
  *
24
- * Absent when the index is current. Otherwise `staleness.status` says what git
25
- * could establish: `behind` with the counted `commitsBehind`; `diverged` when
26
- * HEAD has provably moved off the indexed commit but the history needed to
27
- * count the gap is gone — the state a branch-pinned `url` clone reaches once
28
- * git prunes the commit a failed re-index left behind; or `unknown` when the
29
- * repository could not be measured at all. This is a listing a monitor reads,
30
- * so `unknown` is included here, unlike on the hot read tools.
24
+ * This listing/HTTP helper uses the no-ref form: absent when the index is
25
+ * current; `unknown` is included via `includeUnknown`. Otherwise
26
+ * `staleness.status` says what git could establish: `behind` with the counted
27
+ * `commitsBehind`; `diverged` when HEAD has provably moved off the indexed
28
+ * commit but the history needed to count the gap is gone — the state a
29
+ * branch-pinned `url` clone reaches once git prunes the commit a failed
30
+ * re-index left behind; or `unknown` when the repository could not be
31
+ * measured at all.
32
+ *
33
+ * Hot read tools (`query`/`context`/`impact`/`cypher`) use the ref-carrying
34
+ * form: they emit `unknown` (and `current`) with `branch?`/`lastCommit`/
35
+ * `indexedAt`/`measuredAgainst`.
31
36
  *
32
37
  * All of it measures the index against the local working tree — the same thing
33
38
  * `gitnexus status` and MCP `list_repos` measure — not against the remote.
@@ -14,13 +14,18 @@ import { stalenessPayload, } from '../core/staleness-status.js';
14
14
  * Staleness through the shared {@link stalenessPayload} builder, so this route
15
15
  * and MCP `list_repos` emit one shape for one fact (#3232 review, #3256).
16
16
  *
17
- * Absent when the index is current. Otherwise `staleness.status` says what git
18
- * could establish: `behind` with the counted `commitsBehind`; `diverged` when
19
- * HEAD has provably moved off the indexed commit but the history needed to
20
- * count the gap is gone — the state a branch-pinned `url` clone reaches once
21
- * git prunes the commit a failed re-index left behind; or `unknown` when the
22
- * repository could not be measured at all. This is a listing a monitor reads,
23
- * so `unknown` is included here, unlike on the hot read tools.
17
+ * This listing/HTTP helper uses the no-ref form: absent when the index is
18
+ * current; `unknown` is included via `includeUnknown`. Otherwise
19
+ * `staleness.status` says what git could establish: `behind` with the counted
20
+ * `commitsBehind`; `diverged` when HEAD has provably moved off the indexed
21
+ * commit but the history needed to count the gap is gone — the state a
22
+ * branch-pinned `url` clone reaches once git prunes the commit a failed
23
+ * re-index left behind; or `unknown` when the repository could not be
24
+ * measured at all.
25
+ *
26
+ * Hot read tools (`query`/`context`/`impact`/`cypher`) use the ref-carrying
27
+ * form: they emit `unknown` (and `current`) with `branch?`/`lastCommit`/
28
+ * `indexedAt`/`measuredAgainst`.
24
29
  *
25
30
  * All of it measures the index against the local working tree — the same thing
26
31
  * `gitnexus status` and MCP `list_repos` measure — not against the remote.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.5",
3
+ "version": "1.6.13-rc.7",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -42,6 +42,7 @@ diagnosis.
42
42
  ```
43
43
 
44
44
  > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
45
+ > 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.
45
46
 
46
47
  ## Checklist
47
48
 
@@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation.
36
36
  ```
37
37
 
38
38
  > If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
39
+ > 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.
39
40
 
40
41
  ## Checklist
41
42
 
@@ -16,6 +16,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
16
16
  3. **Follow the skill's workflow and checklist**
17
17
 
18
18
  > If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
19
+ > On `query` / `context` / `impact` / `cypher`, read `staleness.status` and `staleness.branch`/`lastCommit` before using the answer. Re-analyze only for `behind` or `diverged`.
19
20
 
20
21
  ## Skills
21
22
 
@@ -81,6 +82,54 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
81
82
 
82
83
  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.
83
84
 
85
+ ### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
86
+
87
+ 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):
88
+
89
+ ```jsonc
90
+ { /* …the tool's normal result… */
91
+ "staleness": {
92
+ "status": "current",
93
+ "branch": "feature/checkout-v2",
94
+ "lastCommit": "4f2a1c9e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d",
95
+ "indexedAt": "2026-09-15T07:12:00.000Z",
96
+ "measuredAgainst": "HEAD"
97
+ }
98
+ }
99
+ ```
100
+
101
+ `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.
102
+
103
+ When the index is behind that HEAD, the count and hint ride along:
104
+
105
+ ```jsonc
106
+ { /* …the tool's normal result… */
107
+ "staleness": {
108
+ "status": "behind", "commitsBehind": 3, "branch": "main",
109
+ "lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
110
+ "indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
111
+ "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update."
112
+ }
113
+ }
114
+ ```
115
+
116
+ `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:
117
+
118
+ ```jsonc
119
+ { /* …the tool's normal result… */
120
+ "staleness": {
121
+ "status": "diverged", "branch": "main",
122
+ "lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0",
123
+ "indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD",
124
+ "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."
125
+ }
126
+ }
127
+ ```
128
+
129
+ 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.
130
+
131
+ `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.
132
+
84
133
  ### Taint findings (`explain`)
85
134
 
86
135
  `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.
@@ -53,6 +53,7 @@ Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEA
53
53
  ```
54
54
 
55
55
  > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
56
+ > 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.
56
57
  > If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands.
57
58
 
58
59
  ## Checklist
@@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor.
46
46
  ```
47
47
 
48
48
  > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
49
+ > 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.
49
50
 
50
51
  ## Checklists
51
52