mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
* feat(cursor): upgrade hooks to Cursor 2.4 postToolUse for Read/Grep/Shell coverage
Cursor 2.4 (released 2026-01-22) shipped generic preToolUse/postToolUse hooks
matching `Shell|Read|Write|Grep|Delete|Task|MCP:<tool>`, replacing the
2.3-era beforeShellExecution hook that only fired on shell commands. The
existing integration only intercepted the shell path, so Cursor users got
graph augmentation roughly 10% as often as Claude Code users — only when
the agent dropped to rg/grep instead of using its native Read/Grep tools.
This swaps the integration over to postToolUse and ports the bash+jq
hook script to cross-platform Node:
- gitnexus-cursor-integration/hooks/hooks.json: registers a single
postToolUse hook matching Shell|Read|Grep that invokes the new
gitnexus-hook.cjs.
- gitnexus-cursor-integration/hooks/gitnexus-hook.cjs: new Node hook
mirroring the safety patterns from the Claude hook (absolute-cwd
validation, .gitnexus discovery with linked-worktree fallback,
npx.cmd on Windows, end-of-options `--` marker, debug truncation,
graceful failure). Extracts the search pattern per tool kind:
Grep -> toolInput.query; Read -> file basename stripped to identifier
chars; Shell -> existing rg/grep arg parser. Emits Cursor-shape
`{ "additional_context": "..." }` on stdout — no shell, no jq.
- gitnexus-cursor-integration/hooks/augment-shell.sh: removed (Windows
incompatible, narrower coverage).
- gitnexus/test/unit/cursor-hook.test.ts: 33 regression tests covering
manifest wiring, source-level invariants (no shell:true, npx.cmd,
isAbsolute, additional_context output shape, end-of-options marker),
extractPattern coverage per tool, and behavioral early-exit paths
(empty/invalid stdin, relative cwd, no .gitnexus, unknown tool name,
short patterns, non-search shell commands, case-insensitive matching).
- README.md / gitnexus/README.md: editor-support table now lists Cursor
as Full / hooks=Yes (postToolUse), matching reality.
- gitnexus/src/cli/augment.ts and gitnexus/src/core/augmentation/engine.ts:
doc-strings updated from `Cursor beforeShellExecution` to
`Cursor postToolUse`.
Closes #1466.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cursor): hook timeout is in seconds, not milliseconds
Cursor's `timeout` field in hooks.json is in seconds (per
https://cursor.com/docs/agent/hooks and the original integration's
`"timeout": 5`). I'd written `10000` after blindly copying the issue
body's example — that resolves to ~2.8 hours, not 10 seconds. If the
script ever hangs before reaching its inner spawnSync timeouts (e.g.
during stdin read), Cursor would have waited that long before killing
it.
Drop to `10` (seconds), matching the Claude plugin's hooks.json and
giving plenty of headroom over the inner 7s augment-CLI timeout.
Add a regression-guard assertion in cursor-hook.test.ts so a future
ms/s mixup fails fast.
Reported by Cursor Bugbot on PR #1467.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cursor): address Claude review findings — payload aliases, debug, install docs
Resolves three findings from Claude reviewer on PR #1467:
1. Cursor payload field-name uncertainty (SIGNIFICANT)
Claude flagged that the Grep `query` field is an unverified assumption
per Cursor 2.4 docs (https://cursor.com/docs/agent/hooks). Mitigated:
- Expanded Grep aliases: query | pattern | regex | q | search | searchQuery
- Added pickLongestStringValue() last-resort fallback so the hook
extracts *something* even if Cursor renames every documented field
- Added GITNEXUS_DEBUG=1 stderr logging of the raw stdin payload so
users can capture Cursor's actual contract when diagnosing silent
no-ops, and report it back if aliases drift
- Added Read alias `filePath` (camelCase variant alongside `file_path`)
- Inline comment block citing the docs URL and the uncertainty
2. Hook command path resolution + install docs (SIGNIFICANT)
Claude flagged `node ./hooks/gitnexus-hook.cjs` as relative without
documented install path. Added gitnexus-cursor-integration/README.md
with explicit install steps:
- .cursor/hooks.json + hooks/gitnexus-hook.cjs at project root
- Confirms Cursor's project-root CWD convention with doc link
- Verify steps including GITNEXUS_DEBUG capture
- Pattern-extraction contract table per tool
- Troubleshooting: not-firing, npx fallback, wrong-pattern diagnosis
3. README "Full" overclaim for Cursor (MODERATE)
Both README rows now read `Yes (postToolUse, manual install)` linking
to the new install README, accurately signaling that hooks aren't
automated by `gitnexus setup` like they are for Claude Code.
4. Shell quoted-pattern parser limitation (MINOR, documented)
Added inline comment in gitnexus-hook.cjs documenting the known
`rg "User Service"` -> `User` truncation, plus regression tests in
cursor-hook.test.ts pinning the behavior so a future change is
visible.
Test additions (33 -> 41):
- Wide-alias source coverage for Grep (query / pattern / regex / q /
search / searchQuery) plus pickLongestStringValue fallback
- Read alias coverage including camelCase filePath
- GITNEXUS_DEBUG behavioral test: stderr quiet by default, payload
echoed when env var set, stdout output contract preserved either way
- Shell quoted-pattern documented behavior tests
- Install README presence + content (.cursor/hooks.json, hooks/, debug
diagnostics)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
298 lines
8.9 KiB
TypeScript
298 lines
8.9 KiB
TypeScript
/**
|
|
* Augmentation Engine
|
|
*
|
|
* Lightweight, fast-path enrichment of search patterns with knowledge graph context.
|
|
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor postToolUse)
|
|
* when an agent runs grep/glob/read/search.
|
|
*
|
|
* Performance target: <500ms cold start, <200ms warm.
|
|
*
|
|
* Design decisions:
|
|
* - Uses only BM25 search (no semantic/embedding) for speed
|
|
* - Clusters used internally for ranking, NEVER in output
|
|
* - Output is pure relationships: callers, callees, process participation
|
|
* - Graceful failure: any error → return empty string
|
|
*/
|
|
|
|
import path from 'path';
|
|
import { listRegisteredRepos } from '../../storage/repo-manager.js';
|
|
|
|
/**
|
|
* Find the best matching repo for a given working directory.
|
|
* Matches by checking if cwd is within the repo's path.
|
|
*/
|
|
async function findRepoForCwd(cwd: string): Promise<{
|
|
name: string;
|
|
storagePath: string;
|
|
lbugPath: string;
|
|
} | null> {
|
|
try {
|
|
const entries = await listRegisteredRepos({ validate: true });
|
|
const resolved = path.resolve(cwd);
|
|
|
|
// Normalize to lowercase on Windows (drive letters can differ: D: vs d:)
|
|
const isWindows = process.platform === 'win32';
|
|
const normalizedCwd = isWindows ? resolved.toLowerCase() : resolved;
|
|
const sep = path.sep;
|
|
|
|
// Find the LONGEST matching repo path (most specific match wins)
|
|
let bestMatch: (typeof entries)[0] | null = null;
|
|
let bestLen = 0;
|
|
|
|
for (const entry of entries) {
|
|
const repoResolved = path.resolve(entry.path);
|
|
const normalizedRepo = isWindows ? repoResolved.toLowerCase() : repoResolved;
|
|
|
|
// Check if cwd is inside repo OR repo is inside cwd
|
|
// Must match at a path separator boundary to avoid false positives
|
|
// (e.g. /projects/gitnexusv2 should NOT match /projects/gitnexus)
|
|
let matched = false;
|
|
if (normalizedCwd === normalizedRepo) {
|
|
matched = true;
|
|
} else if (normalizedCwd.startsWith(normalizedRepo + sep)) {
|
|
matched = true;
|
|
} else if (normalizedRepo.startsWith(normalizedCwd + sep)) {
|
|
matched = true;
|
|
}
|
|
|
|
if (matched && normalizedRepo.length > bestLen) {
|
|
bestMatch = entry;
|
|
bestLen = normalizedRepo.length;
|
|
}
|
|
}
|
|
|
|
if (!bestMatch) return null;
|
|
|
|
return {
|
|
name: bestMatch.name,
|
|
storagePath: bestMatch.storagePath,
|
|
lbugPath: path.join(bestMatch.storagePath, 'lbug'),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Augment a search pattern with knowledge graph context.
|
|
*
|
|
* 1. BM25 search for the pattern
|
|
* 2. For top matches, fetch callers/callees/processes
|
|
* 3. Rank by internal cluster cohesion (not exposed)
|
|
* 4. Format as structured text block
|
|
*
|
|
* Returns empty string on any error (graceful failure).
|
|
*/
|
|
export async function augment(pattern: string, cwd?: string): Promise<string> {
|
|
if (!pattern || pattern.length < 3) return '';
|
|
|
|
const workDir = cwd || process.cwd();
|
|
|
|
try {
|
|
const repo = await findRepoForCwd(workDir);
|
|
if (!repo) return '';
|
|
|
|
// Lazy-load lbug adapter (skip unnecessary init)
|
|
const { initLbug, executeQuery, isLbugReady } = await import('../lbug/pool-adapter.js');
|
|
const { searchFTSFromLbug } = await import('../search/bm25-index.js');
|
|
|
|
const repoId = repo.name.toLowerCase();
|
|
|
|
// Init LadybugDB if not already
|
|
if (!isLbugReady(repoId)) {
|
|
await initLbug(repoId, repo.lbugPath);
|
|
}
|
|
|
|
// Step 1: BM25 search (fast, no embeddings)
|
|
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
|
|
|
|
if (bm25Results.length === 0) return '';
|
|
|
|
// Step 2: Map BM25 file results to symbols
|
|
const symbolMatches: Array<{
|
|
nodeId: string;
|
|
name: string;
|
|
type: string;
|
|
filePath: string;
|
|
score: number;
|
|
}> = [];
|
|
|
|
for (const result of bm25Results.slice(0, 5)) {
|
|
const escaped = result.filePath.replace(/'/g, "''");
|
|
try {
|
|
const symbols = await executeQuery(
|
|
repoId,
|
|
`
|
|
MATCH (n) WHERE n.filePath = '${escaped}'
|
|
AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}'
|
|
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
|
LIMIT 3
|
|
`,
|
|
);
|
|
for (const sym of symbols) {
|
|
symbolMatches.push({
|
|
nodeId: sym.id || sym[0],
|
|
name: sym.name || sym[1],
|
|
type: sym.type || sym[2],
|
|
filePath: sym.filePath || sym[3],
|
|
score: result.score,
|
|
});
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
|
|
if (symbolMatches.length === 0) return '';
|
|
|
|
// Step 3: Batch-fetch callers/callees/processes/cohesion for top matches
|
|
// Uses batched WHERE n.id IN [...] queries instead of per-symbol queries
|
|
const uniqueSymbols = symbolMatches
|
|
.slice(0, 5)
|
|
.filter((sym, i, arr) => arr.findIndex((s) => s.nodeId === sym.nodeId) === i);
|
|
|
|
if (uniqueSymbols.length === 0) return '';
|
|
|
|
const idList = uniqueSymbols.map((s) => `'${s.nodeId.replace(/'/g, "''")}'`).join(', ');
|
|
|
|
// Batch fetch callers
|
|
const callersMap = new Map<string, string[]>();
|
|
try {
|
|
const rows = await executeQuery(
|
|
repoId,
|
|
`
|
|
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n)
|
|
WHERE n.id IN [${idList}]
|
|
RETURN n.id AS targetId, caller.name AS name
|
|
LIMIT 15
|
|
`,
|
|
);
|
|
for (const r of rows) {
|
|
const tid = r.targetId || r[0];
|
|
const name = r.name || r[1];
|
|
if (tid && name) {
|
|
if (!callersMap.has(tid)) callersMap.set(tid, []);
|
|
callersMap.get(tid)!.push(name);
|
|
}
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
|
|
// Batch fetch callees
|
|
const calleesMap = new Map<string, string[]>();
|
|
try {
|
|
const rows = await executeQuery(
|
|
repoId,
|
|
`
|
|
MATCH (n)-[:CodeRelation {type: 'CALLS'}]->(callee)
|
|
WHERE n.id IN [${idList}]
|
|
RETURN n.id AS sourceId, callee.name AS name
|
|
LIMIT 15
|
|
`,
|
|
);
|
|
for (const r of rows) {
|
|
const sid = r.sourceId || r[0];
|
|
const name = r.name || r[1];
|
|
if (sid && name) {
|
|
if (!calleesMap.has(sid)) calleesMap.set(sid, []);
|
|
calleesMap.get(sid)!.push(name);
|
|
}
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
|
|
// Batch fetch processes
|
|
const processesMap = new Map<string, string[]>();
|
|
try {
|
|
const rows = await executeQuery(
|
|
repoId,
|
|
`
|
|
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
|
WHERE n.id IN [${idList}]
|
|
RETURN n.id AS nodeId, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
|
|
`,
|
|
);
|
|
for (const r of rows) {
|
|
const nid = r.nodeId || r[0];
|
|
const label = r.label || r[1];
|
|
const step = r.step || r[2];
|
|
const stepCount = r.stepCount || r[3];
|
|
if (nid && label) {
|
|
if (!processesMap.has(nid)) processesMap.set(nid, []);
|
|
processesMap.get(nid)!.push(`${label} (step ${step}/${stepCount})`);
|
|
}
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
|
|
// Batch fetch cohesion
|
|
const cohesionMap = new Map<string, number>();
|
|
try {
|
|
const rows = await executeQuery(
|
|
repoId,
|
|
`
|
|
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
|
WHERE n.id IN [${idList}]
|
|
RETURN n.id AS nodeId, c.cohesion AS cohesion
|
|
`,
|
|
);
|
|
for (const r of rows) {
|
|
const nid = r.nodeId || r[0];
|
|
const coh = r.cohesion ?? r[1] ?? 0;
|
|
if (nid) cohesionMap.set(nid, coh);
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
|
|
// Assemble enriched results
|
|
const enriched: Array<{
|
|
name: string;
|
|
filePath: string;
|
|
callers: string[];
|
|
callees: string[];
|
|
processes: string[];
|
|
cohesion: number;
|
|
}> = [];
|
|
|
|
for (const sym of uniqueSymbols) {
|
|
enriched.push({
|
|
name: sym.name,
|
|
filePath: sym.filePath,
|
|
callers: (callersMap.get(sym.nodeId) || []).slice(0, 3),
|
|
callees: (calleesMap.get(sym.nodeId) || []).slice(0, 3),
|
|
processes: processesMap.get(sym.nodeId) || [],
|
|
cohesion: cohesionMap.get(sym.nodeId) || 0,
|
|
});
|
|
}
|
|
|
|
if (enriched.length === 0) return '';
|
|
|
|
// Step 4: Rank by cohesion (internal signal) and format
|
|
enriched.sort((a, b) => b.cohesion - a.cohesion);
|
|
|
|
const lines: string[] = [`[GitNexus] ${enriched.length} related symbols found:`, ''];
|
|
|
|
for (const item of enriched) {
|
|
lines.push(`${item.name} (${item.filePath})`);
|
|
if (item.callers.length > 0) {
|
|
lines.push(` Called by: ${item.callers.join(', ')}`);
|
|
}
|
|
if (item.callees.length > 0) {
|
|
lines.push(` Calls: ${item.callees.join(', ')}`);
|
|
}
|
|
if (item.processes.length > 0) {
|
|
lines.push(` Flows: ${item.processes.join(', ')}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n').trim();
|
|
} catch {
|
|
// Graceful failure — never break the original tool
|
|
return '';
|
|
}
|
|
}
|