GitNexus/gitnexus-factory-plugin/hooks/gitnexus-hook.js
Joseph Yared b92c14cdd0
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat: add Factory AI (Droid) integration (#2543)
* feat(setup): add Factory Droid (MCP + skills) to gitnexus setup

Register 'droid' in the editor-targets abstraction so `gitnexus setup -c droid`
writes the MCP server to ~/.factory/mcp.json and installs skills to
~/.factory/skills/ from the single canonical skills/ source (no per-editor
copies). uninstall.ts is target-driven, so removal is covered automatically.
Adds unit + round-trip coverage.

* feat(plugin): add gitnexus-factory-plugin for droid plugin install

* docs: add Factory Droid to editor support table and setup docs

* fix(factory-plugin): guard augment hook against fan-out and DB contention

Reuse the Claude adapter's acquireHookSlot and LadybugDB owner probe
(bundled byte-identical, kept in lockstep by a drift test) instead of
running an unguarded augment. Add direct tests for the hook and manifests.

* docs: align Factory row in editor support table

* fix(factory-plugin): honor GITNEXUS_HOOK_CLI_PATH so augment runs on Windows

* docs(hooks): point bundled guard copies at their drift tests

* docs(factory-plugin): note the Execute tokenizer's quoting limit

* docs(readme): clarify the Full tier and group the Factory row

* docs(hooks): trim drift note to a single line

* test(ci): run factory-plugin tests on the windows cross-platform lane

* refactor(hooks): drop the drift-note comments, the tests already enforce it

* fix(factory-plugin): pin CLI version and parse quoted shell patterns

- Pin mcp.json and the hook's npx fallback to gitnexus@<version> from
  the plugin manifest, registered with the release sync script so a
  mutable @latest can never execute on MCP connect or augment fallback
- Port the #2938 shell tokenizer (tokenizeShellWords + parseRgGrepPattern)
  so quoted, backslash-escaped, --regexp=, -eVALUE, and -- patterns survive
- Add the #2938 regression matrix and pin assertions to factory-plugin.test.ts

* docs: add Factory Droid to published npm README

* fix(factory-plugin): wire marketplace so droid installs the Factory plugin

Add .factory-plugin/marketplace.json sourcing ./gitnexus-factory-plugin.
Droid reads it before .claude-plugin/marketplace.json, so
`droid plugin install` now delivers the Factory plugin (Execute matcher,
pinned mcp.json) instead of the translated Claude plugin (Bash matcher,
gitnexus@latest). Register the surface in the version-sync script and
cover the wiring in the factory and sync test suites.

* fix(factory-plugin): use registry lookup for index resolution

Bundle registry-query.cjs so external indexes resolve (#3060); re-pin to 1.6.12.

* fix(factory-plugin): sync Execute parser with Cursor hook

Fixes echo-rg and -f false positives; tighten test env isolation.

* fix(factory-plugin): stop no-match augment from re-running via npx

A PATH `gitnexus` that finds no match exits 0 with empty stderr, which
fell through to a second `npx -y gitnexus@<pin> augment` with its own 8s
timeout (16s worst case vs the 10s hook budget). Fall through to npx only
when the PATH launcher is missing (ENOENT); any launched PATH binary,
including a timeout or non-zero exit, now ends the augment.

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

* fix(factory-plugin): filter augment stderr to the [GitNexus] block

runAugment returned raw child stderr, so npm/Node/LadybugDB warnings leaked
into additionalContext and noise-only stderr counted as success. Port the
Claude adapter's extractAugmentContext (verbatim, with isDebugEnabled) and
apply it on every launch tier before the success decision. Adds a drift test
against the Claude copy and PATH-tier noise/noise-only behavior tests.

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

* fix(factory-plugin): quote DROID_PLUGIN_ROOT in hook command

An unquoted plugin root containing spaces (e.g. a Windows user profile
path) split into multiple argv words, so the PostToolUse hook silently
never ran. Quote it like the Claude plugin does, and pin the exact
quoted command in the hooks.json wiring test.

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

* fix(release): stage Factory plugin manifests in the release commit

The rc release job stages only the original four manifest surfaces in the
detached release commit, so the v<version> tag tree carried the Factory
plugin.json, mcp.json and marketplace.json at the previous version while
--check (working tree) passed. Stage them too, and guard the git add block
against the synced surfaces in sync-plugin-manifests.test.ts.

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

* refactor(factory-plugin): simplify hook gates, spawn tiers and tests

- main(): resolve the repo only after the tool-name and pattern gates,
  matching the Claude/Cursor hook order (skips fs/git work on no-op calls).
- runAugment(): share one spawnAugment helper between the
  GITNEXUS_HOOK_CLI_PATH and npx tiers; PATH tier ENOENT logic unchanged.
- factory-plugin test: pre-filter comment lines instead of `continue`.
- sync-plugin-manifests test: hoist EXECUTABLE_MCP_FILES and derive
  TOTAL_SURFACES from its length.
- fnSource(): throw when the function or its closing brace is not found.

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

* fix(cli): list Factory Droid in localized setup help

`localizeCliHelp` overwrites the `setup` command description with the
`help.command.setup.description` i18n key, so the literal edited in
index.ts never reached `gitnexus setup --help`. Add Factory Droid to the
en and zh-CN keys.

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

* Address PR review feedback (#2543)

- factory hook: run every augment tier under the bundled Unix timeout
  guard (npx tier group-kills), keeping exactly-one-tier fall-through
- hook-db-lock-probe: trim GITNEXUS_HOOK_{LSOF,PS}_PATH once so a padded
  override is used, not silently replaced (all 3 copies)
- hook-lock: evict a stale slot via rename-to-tombstone + identity check,
  so a concurrently recreated fresh lock is never deleted (all 4 copies)
- registry-query: a set-but-invalid storage override resolves no repo
  instead of falling back to the registry storagePath (all 4 copies)
- publish.yml: stage the ten skill mcp.json manifests in the rc release
  commit; the staging test now requires every synced surface

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

* Address PR review feedback round 2 (#2543)

- hook-lock: replace rename-to-tombstone eviction with an O_EXCL per-slot
  `.evicting` marker plus an identity re-check before unlink, so a live
  lock is never moved, and a crashed evictor leaves only a self-expiring
  marker (all 4 copies)
- hook-db-lock-probe: clamp GITNEXUS_HOOK_PROC_CMDLINE_MAX to a named
  256 KiB ceiling and require an integer, so an oversized override can
  no longer fail the buffer allocation and miss a live owner (all 3 copies)
- registry-query: treat an empty GITNEXUS_STORAGE_PATH/ROOT as set but
  invalid, matching the CLI's `!== undefined` rule (all 4 copies); the
  factory test env now deletes those keys instead of blanking them

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

* Address PR review feedback round 3 (#2543)

- hook-db-lock-probe: a capped /proc cmdline read stops early only once
  both the GitNexus token and the mcp/serve mode are present (or at EOF,
  the ceiling, or the budget), so a mode word such as `--require mcp`
  before the GitNexus path no longer hides a live owner (all 3 copies)
- registry-query: correct the override comment; a filesystem root is
  invalid only for GITNEXUS_STORAGE_PATH, not GITNEXUS_STORAGE_ROOT
  (all 4 copies, comment only)

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

* Address PR review feedback round 4 (#2543)

- hook-db-lock-probe: an fd-directory read error other than ENOENT or
  ENOTDIR on an identified server candidate now fails closed ('timeout')
  instead of reporting not-owned (EMFILE/ENFILE/ENOMEM/EINTR)
- hook-db-lock-probe: resolve GITNEXUS_HOOK_TIMEOUT_PATH to an absolute
  path before validating and caching it, so callers that spawn with a
  request cwd can still execute the guard
- hook-db-lock-probe: document the chunked cmdline read's actual stop
  conditions (all 3 copies)

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

* Harden hook-lock eviction marker lifecycle (#2543)

Per the chosen option (B) for the stale-slot eviction race:
- `.evicting` markers carry a per-call owner token (pid + random hex)
- an evictor re-reads its token immediately before the slot identity
  check and unlink; a stalled evictor whose marker was broken backs off
- `finally` removes the marker only while it still holds our token
- an orphaned marker is broken only if, re-checked just before unlink,
  its bigint identity and token are unchanged from when judged stale
- doc comment states the two remaining two-syscall windows (slot
  lstat->unlink, marker token->unlink); POSIX has no conditional
  unlink, and the worst case is one extra concurrent augment

All four byte-identical hook-lock copies updated.

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

* Fix CodeQL file-system race in hook-lock orphan-marker check (#2543)

breakOrphanedMarker stat'd the marker by path and then read it by path,
which CodeQL flags (js/file-system-race): the file could be replaced
between the two calls. Take the stat and the token from one open
descriptor (readMarkerSnapshot, O_NOFOLLOW where available) for both the
"judged stale" snapshot and the pre-unlink re-check. All four hook-lock
copies updated.

The replaced-marker test injected its swap via a readFileSync(path) spy,
which no longer fires; it now swaps the marker just before its second
open, counting opens of the marker path only (a per-path counter fired
early on slot-0 and let a mutant pass).

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

* Unregister hook-lock exit listener on release (#2543)

Each acquireHookSlot registered `release` as a process 'exit' listener
that was never removed, so a long-lived process acquiring and releasing
slots repeatedly would accumulate listeners (MaxListenersExceededWarning)
and retain every closure. release() now removes itself. All four
hook-lock copies updated; a test asserts 12 acquire/release cycles leave
the 'exit' listener count unchanged.

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

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:12:40 +01:00

510 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* GitNexus Factory AI (Droid) plugin hook.
*
* PostToolUse — augments Grep/Glob/Execute searches with graph context and
* returns it via hookSpecificOutput.additionalContext.
*
* Reuses the Claude adapter's guards, bundled byte-identical: acquireHookSlot
* caps concurrent augment children per repo (#1486), and the LadybugDB owner
* probe skips the CLI augment when an MCP/serve process already holds the
* single-writer lock (#2396). The repo and its index storage are resolved via
* the same bundled registry lookup (registry-query.cjs), so external and
* branch-slot indexes work (#3060). On Unix the augment child runs under the
* probe's self-tested coreutils `timeout` guard, as in the Claude adapter
* (#2163), so a hook the runner kills cannot strand the CLI (see runAugment).
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { acquireHookSlot } = require('./hook-lock.js');
const {
hasGitNexusDbLockedByGitNexusServer,
resolveUnixGuardTimeout,
} = require('./hook-db-lock-probe.cjs');
const { resolveHookRepo } = require('./registry-query.cjs');
// Pin the CLI instead of tracking `latest`: npm versions are immutable, so only
// a plugin revision can change what the fallback below executes. The release
// stamps this manifest (gitnexus/scripts/sync-plugin-manifests.mjs).
const { version: PINNED_VERSION } = require('../.factory-plugin/plugin.json');
function readInput() {
try {
return JSON.parse(fs.readFileSync(0, 'utf-8'));
} catch {
return {};
}
}
/**
* Split a command the way a POSIX shell would, so quoted and backslash-escaped
* patterns survive as one token. Kept identical to the Cursor adapter's
* tokenizer (#2938) so the two can collapse into a shared module later.
*/
function tokenizeShellWords(command) {
const tokens = [];
let current = '';
let quote = null;
let escaped = false;
let hasToken = false;
for (let index = 0; index < command.length; index += 1) {
const char = command[index];
if (escaped) {
current += char;
escaped = false;
hasToken = true;
continue;
}
if (quote === "'") {
if (char === "'") quote = null;
else current += char;
hasToken = true;
continue;
}
if (quote === '"') {
if (char === '"') {
quote = null;
} else if (char === '\\') {
const next = command[index + 1];
if (next === '$' || next === '`' || next === '"' || next === '\\') {
escaped = true;
} else {
current += '\\';
}
} else {
current += char;
}
hasToken = true;
continue;
}
if (char === '\\') {
const next = command[index + 1];
if (next === undefined || /\s/.test(next) || next === "'" || next === '"' || next === '\\') {
escaped = true;
} else {
current += '\\' + next;
index += 1;
}
hasToken = true;
} else if (char === "'" || char === '"') {
quote = char;
hasToken = true;
} else if (/\s/.test(char)) {
if (hasToken) tokens.push(current);
current = '';
hasToken = false;
} else if (char === ';' || char === '|' || char === '&') {
if (hasToken) tokens.push(current);
current = '';
hasToken = false;
const next = command[index + 1];
if ((char === '|' || char === '&') && next === char) {
tokens.push(char + char);
index += 1;
} else {
tokens.push(char);
}
} else {
current += char;
hasToken = true;
}
}
if (escaped) current += '\\';
if (hasToken) tokens.push(current);
return tokens;
}
/** Recover the search pattern from an `rg`/`grep` command line. */
function parseRgGrepPattern(cmd) {
const tokens = tokenizeShellWords(cmd);
let foundCmd = false;
let skipNext = false;
let skipNextAsPattern = false;
let endOfOptions = false;
let explicitPatternSeen = false;
let patternFileSeen = false;
const flagsWithValues = new Set([
'-e',
'-f',
'--file',
'-m',
'--max-count',
'-A',
'-B',
'-C',
'-g',
'--glob',
'--iglob',
'-t',
'--type',
'--include',
'--exclude',
'--encoding',
'--path',
]);
const rgValueFlags = new Set(['-r', '--replace']);
const patternFlags = new Set(['-e', '--regexp']);
const connectors = new Set(['&&', '||', ';', '|', '&']);
const wrappers = new Set([
'npx',
'bunx',
'pnpm',
'yarn',
'npm',
'sudo',
'env',
'command',
'time',
'nice',
'xargs',
'dlx',
'exec',
'run',
'git',
]);
const wrapperFlagsWithValues = new Set([
'--package',
'-p',
'--call',
'--prefix',
'--shell',
'--filter',
'--workspace',
'--dir',
'--cwd',
]);
const basename = (token) =>
token
.split(/[\\/]/)
.pop()
?.replace(/\.(exe|cmd|bat)$/i, '');
let previousToken;
let seenWrapper = false;
let searchCommand = null;
for (const token of tokens) {
if (skipNext) {
skipNext = false;
if (skipNextAsPattern) {
skipNextAsPattern = false;
if (token.length >= 3) return token;
}
previousToken = token;
continue;
}
if (!foundCmd) {
if (connectors.has(token)) {
seenWrapper = false;
previousToken = token;
continue;
}
const commandName = basename(token);
if (wrappers.has(commandName)) {
seenWrapper = true;
previousToken = token;
continue;
}
if (seenWrapper && token.startsWith('-')) {
const flagName = token.split('=', 1)[0];
if (!token.includes('=') && wrapperFlagsWithValues.has(flagName)) skipNext = true;
previousToken = token;
continue;
}
if (seenWrapper && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
previousToken = token;
continue;
}
const atCommandPosition =
previousToken === undefined ||
connectors.has(previousToken) ||
wrappers.has(basename(previousToken)) ||
seenWrapper;
if (atCommandPosition && (commandName === 'rg' || commandName === 'grep')) {
foundCmd = true;
searchCommand = commandName;
} else if (seenWrapper) {
seenWrapper = false;
}
previousToken = token;
continue;
}
previousToken = token;
if (endOfOptions) {
if (explicitPatternSeen || patternFileSeen) continue;
return token.length >= 3 ? token : null;
}
if (token === '--') {
endOfOptions = true;
continue;
}
if (token.startsWith('-')) {
if (token === '-f' || token === '--file') {
skipNext = true;
patternFileSeen = true;
continue;
}
if (token.startsWith('--file=')) {
patternFileSeen = true;
continue;
}
if (token.startsWith('--regexp=')) {
explicitPatternSeen = true;
const value = token.slice('--regexp='.length);
if (value.length >= 3) return value;
continue;
}
const attachedPattern = token.match(/^-e(.+)$/);
if (attachedPattern) {
explicitPatternSeen = true;
if (attachedPattern[1].length >= 3) return attachedPattern[1];
continue;
}
if (
flagsWithValues.has(token) ||
patternFlags.has(token) ||
(searchCommand === 'rg' && rgValueFlags.has(token))
) {
skipNext = true;
skipNextAsPattern = patternFlags.has(token);
if (skipNextAsPattern) explicitPatternSeen = true;
}
continue;
}
if (explicitPatternSeen || patternFileSeen) continue;
return token.length >= 3 ? token : null;
}
return null;
}
/** Factory's shell tool is `Execute` (Claude's is `Bash`); Grep/Glob match Claude's. */
function extractPattern(toolName, toolInput) {
if (toolName === 'Grep') {
return toolInput.pattern || null;
}
if (toolName === 'Glob') {
const raw = toolInput.pattern || '';
const match = raw.match(/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/);
return match ? match[1] : null;
}
if (toolName === 'Execute') {
const cmd = toolInput.command || '';
if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
return parseRgGrepPattern(cmd);
}
return null;
}
/**
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
* non-error skip paths must stay silent unless the operator explicitly asks
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
*/
function isDebugEnabled() {
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
}
/**
* Keep only the augment block: stderr from the first `[GitNexus]` marker on, or
* '' when there is none, so npm/Node/LadybugDB warnings never reach the agent.
* Kept identical to the Claude adapter's copy so the two can be shared later.
*/
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = isDebugEnabled();
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
// warnings, parser errors, etc. — remain recoverable on the hook's own
// stderr. The untruncated payload lets operators see exactly what was
// filtered out instead of a 180-char JSON-quoted preview.
const discarded = marker === -1 ? output : output.slice(0, marker).trim();
if (discarded.length > 0) {
process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`);
}
}
return marker === -1 ? '' : output.slice(marker).trim();
}
/**
* Absolute path of a runnable (regular file, X_OK) `command` on PATH, or null.
* POSIX-only: used where the timeout guard would otherwise mask a missing
* launcher as the guard's own exit 127 instead of a spawn ENOENT.
*/
function findOnPath(command) {
for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
const candidate = path.join(dir, command);
try {
if (!fs.statSync(candidate).isFile()) continue;
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
/* not a runnable file here */
}
}
return null;
}
/**
* Run `gitnexus augment` for `pattern` and return its `[GitNexus]` block — the
* augment CLI writes results to stderr because LadybugDB's native module
* captures stdout at the OS fd level. Launcher noise is filtered out by
* extractAugmentContext, so noise-only stderr yields ''.
*
* GITNEXUS_HOOK_CLI_PATH is tried first and run as `node <path>`, the only form
* that works on Windows, where Node refuses to spawn the `.cmd` shims without a
* shell (CVE-2024-27980). Otherwise a PATH binary, and a version-pinned npx
* only when no PATH binary exists. Exactly one tier runs, so a no-match search
* (exit 0, empty stderr) or a timeout never spends a second 8s budget on npx
* past the 10s hook timeout in hooks.json.
*
* Orphan guard (#2163, ported from the Claude adapter's runGitNexusCli): on
* Unix every tier runs under the probe's self-tested coreutils `timeout`, so a
* hook killed by the runner cannot strand the CLI. The direct tiers (the CLI is
* the guard's child) use `-k 1` TERM-first; npx (guard → npx → CLI grandchild)
* uses `-s KILL`, which group-kills at budget — TERM-first would only kill the
* obedient npx parent and let `timeout` exit before its `-k` escalation, leaving
* a SIGTERM-immune CLI running. Residual gaps are the Claude adapter's: a
* busybox guard signals only its direct child, and when the hook itself is
* alive the inner spawnSync timeout SIGTERMs the guard, which forwards TERM, not
* KILL, to the npx group. Because the guard reports a missing command as its
* own exit 127 rather than ENOENT, the guarded PATH tier decides presence with
* findOnPath first. Windows (no coreutils; the self-test spawns /bin/sh) and an
* unresolved guard (e.g. macOS without Homebrew coreutils, or
* GITNEXUS_HOOK_TIMEOUT_PATH=disabled) keep the plain spawn and the ENOENT
* fallthrough.
*
* SECURITY: `pattern` follows the `--` end-of-options marker and never reaches a
* shell (the Windows fallback invokes `npx.cmd` directly rather than
* `shell: true`), so `-rf` or `$(...)` is inert.
*/
function runAugment(pattern, cwd) {
const isWin = process.platform === 'win32';
const args = ['augment', '--', pattern];
const timeoutMs = 8000;
const spawnOpts = {
encoding: 'utf-8',
timeout: timeoutMs,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
};
// An older bundled probe without the export degrades to the unwrapped spawn.
const guard =
isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout();
if (!isWin && !guard && isDebugEnabled()) {
process.stderr.write(
'[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n',
);
}
const guardSecs = String(Math.ceil(timeoutMs / 1000) + 1);
// Only a clean exit 0 yields context; a spawn error, throw or non-zero exit is ''.
// `groupKill` selects the npx arm's `-s KILL` (see the docblock).
const spawnAugment = (cmd, argv, groupKill = false) => {
const [file, fileArgs] = guard
? [guard, [...(groupKill ? ['-s', 'KILL'] : []), '-k', '1', guardSecs, cmd, ...argv]]
: [cmd, argv];
try {
const child = spawnSync(file, fileArgs, spawnOpts);
if (!child.error && child.status === 0) return extractAugmentContext(child.stderr);
} catch {
/* graceful failure */
}
return '';
};
const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH;
if (hookCli && String(hookCli).trim() && fs.existsSync(String(hookCli))) {
return spawnAugment(process.execPath, [String(hookCli), ...args]);
}
if (guard) {
// Guarded (Unix): only a missing launcher falls through to npx.
const launcher = findOnPath('gitnexus');
if (launcher) return spawnAugment(launcher, args);
} else {
// Only ENOENT (no launcher on PATH) falls through to npx. Windows EINVAL for
// `gitnexus.cmd` does not: `npx.cmd` would fail the same way without a shell.
try {
const child = spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, spawnOpts);
if (!child.error || child.error.code !== 'ENOENT') {
return !child.error && child.status === 0 ? extractAugmentContext(child.stderr) : '';
}
} catch (err) {
if (!err || err.code !== 'ENOENT') return '';
}
}
return spawnAugment(
isWin ? 'npx.cmd' : 'npx',
['-y', `gitnexus@${PINNED_VERSION}`, ...args],
true,
);
}
function main() {
try {
const input = readInput();
if ((input.hook_event_name || '') !== 'PostToolUse') return;
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
const toolName = input.tool_name || '';
if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Execute') return;
const pattern = extractPattern(toolName, input.tool_input || {});
if (!pattern || pattern.length < 3) return;
// Registry row first (persisted external storagePath wins); a local owned
// `.gitnexus` is the fallback — same lookup as the Claude/Cursor hooks.
const repo = resolveHookRepo(cwd);
if (!repo) return;
const release = acquireHookSlot(repo.storagePath);
if (!release) return; // all per-repo augment slots held by concurrent sessions
let result = '';
try {
if (hasGitNexusDbLockedByGitNexusServer(repo.lbugPath, process.pid)) {
// #2396: an MCP/serve process owns the single-writer DB, so a competing
// CLI augment would only contend on the lock. Its MCP tools cover
// augmentation instead — skip silently.
return;
}
result = runAugment(pattern, cwd);
} catch {
/* graceful failure */
} finally {
release();
}
if (result && result.trim()) {
console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: result.trim(),
},
}),
);
}
} catch {
/* never let the hook break the tool call */
}
}
if (require.main === module) main();
module.exports = { parseRgGrepPattern, tokenizeShellWords };