diff --git a/.factory-plugin/marketplace.json b/.factory-plugin/marketplace.json
new file mode 100644
index 000000000..56fd22ffd
--- /dev/null
+++ b/.factory-plugin/marketplace.json
@@ -0,0 +1,19 @@
+{
+ "name": "gitnexus-marketplace",
+ "owner": {
+ "name": "GitNexus",
+ "email": "nico@gitnexus.dev"
+ },
+ "metadata": {
+ "description": "Code intelligence powered by a knowledge graph — execution flows, blast radius, and semantic search",
+ "homepage": "https://github.com/nicosxt/gitnexus"
+ },
+ "plugins": [
+ {
+ "name": "gitnexus",
+ "version": "1.6.9",
+ "source": "./gitnexus-factory-plugin",
+ "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
+ }
+ ]
+}
diff --git a/README.md b/README.md
index f2714bf89..51bfd14bc 100644
--- a/README.md
+++ b/README.md
@@ -232,12 +232,13 @@ When a repo contains an `.agents/` directory, the standard and generated skills
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** |
| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** |
+| **Factory** (Droid) | Yes | Yes | Yes (PostToolUse, [plugin](gitnexus-factory-plugin/)) | **Full** |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
-> **Claude Code** and **Codex** get the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
+> **Full** means MCP tools + agent skills + hooks that enrich searches with graph context. **Claude Code** and **Codex** go deepest: their PreToolUse hooks enrich the search before it runs, and their PostToolUse hooks also detect a stale index after commits and prompt the agent to reindex. **Cursor**, **Antigravity**, and **Factory** augment from a post-tool hook only, so they enrich the result rather than the query and do not carry the stale-index hint.
@@ -281,6 +282,21 @@ codex plugin marketplace add abhigyanpatwari/GitNexus
> **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call.
+**Factory** (Droid) — MCP + skills via `gitnexus setup -c droid`, or add the server manually to `~/.factory/mcp.json` ([user scope](https://docs.factory.ai/cli/configuration/mcp), applies to all projects):
+
+```json
+{
+ "mcpServers": {
+ "gitnexus": {
+ "command": "npx",
+ "args": ["-y", "gitnexus@latest", "mcp"]
+ }
+ }
+}
+```
+
+`gitnexus setup -c droid` also installs skills to `~/.factory/skills/`. For the PostToolUse search-augment hook, install the bundled [`gitnexus-factory-plugin/`](gitnexus-factory-plugin/) — from a marketplace that includes this repo, run `droid plugin install gitnexus@`, or point Droid at it via `extraKnownMarketplaces` in `.factory/settings.json`. Factory reads [`AGENTS.md` natively](https://docs.factory.ai/), which already carries the GitNexus context block.
+
**Cursor** (`~/.cursor/mcp.json` — global, works for all projects):
```json
diff --git a/gitnexus-factory-plugin/.factory-plugin/plugin.json b/gitnexus-factory-plugin/.factory-plugin/plugin.json
new file mode 100644
index 000000000..ace058dad
--- /dev/null
+++ b/gitnexus-factory-plugin/.factory-plugin/plugin.json
@@ -0,0 +1,11 @@
+{
+ "name": "gitnexus",
+ "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
+ "version": "1.6.9",
+ "author": {
+ "name": "GitNexus"
+ },
+ "homepage": "https://github.com/abhigyanpatwari/GitNexus",
+ "repository": "https://github.com/abhigyanpatwari/GitNexus",
+ "keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"]
+}
diff --git a/gitnexus-factory-plugin/hooks/gitnexus-hook.js b/gitnexus-factory-plugin/hooks/gitnexus-hook.js
new file mode 100644
index 000000000..e57bae9cb
--- /dev/null
+++ b/gitnexus-factory-plugin/hooks/gitnexus-hook.js
@@ -0,0 +1,332 @@
+#!/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 augment child is not wrapped in the coreutils `timeout` orphan guard the
+ * full Claude adapter uses (#2163) — same scope as the Cursor integration.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { acquireHookSlot } = require('./hook-lock.js');
+const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.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 {};
+ }
+}
+
+/**
+ * A `.gitnexus/` holding `registry.json`/`repos` (and no per-repo index
+ * metadata) is the global registry, not a repo index — never augment against it.
+ */
+function isGlobalRegistryDir(candidate) {
+ if (
+ fs.existsSync(path.join(candidate, 'gitnexus.json')) ||
+ fs.existsSync(path.join(candidate, 'meta.json'))
+ ) {
+ return false;
+ }
+ return (
+ fs.existsSync(path.join(candidate, 'registry.json')) ||
+ fs.existsSync(path.join(candidate, 'repos'))
+ );
+}
+
+/** Walk up from startDir for a non-registry `.gitnexus/`, at most 5 levels. */
+function findGitNexusDir(startDir) {
+ let dir = startDir || process.cwd();
+ for (let i = 0; i < 5; i++) {
+ const candidate = path.join(dir, '.gitnexus');
+ if (fs.existsSync(candidate) && !isGlobalRegistryDir(candidate)) return candidate;
+ const parent = path.dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ return null;
+}
+
+/**
+ * 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];
+ // Inside double quotes a backslash only escapes these four; otherwise
+ // it is a literal character (so Windows paths survive intact).
+ if (next === '$' || next === '`' || next === '"' || next === '\\') {
+ escaped = true;
+ } else {
+ current += '\\';
+ }
+ } else {
+ current += char;
+ }
+ hasToken = true;
+ continue;
+ }
+
+ if (char === '\\') {
+ escaped = true;
+ hasToken = true;
+ } else if (char === "'" || char === '"') {
+ quote = char;
+ hasToken = true;
+ } else if (/\s/.test(char)) {
+ if (hasToken) tokens.push(current);
+ current = '';
+ hasToken = false;
+ } 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;
+ const flagsWithValues = new Set([
+ '-e',
+ '-f',
+ '-m',
+ '-A',
+ '-B',
+ '-C',
+ '-g',
+ '--glob',
+ '-t',
+ '--type',
+ '--include',
+ '--exclude',
+ ]);
+ const patternFlags = new Set(['-e', '--regexp']);
+
+ for (const token of tokens) {
+ if (skipNext) {
+ skipNext = false;
+ if (skipNextAsPattern) {
+ return token.length >= 3 ? token : null;
+ }
+ continue;
+ }
+ if (!foundCmd) {
+ // Match on the basename so absolute paths (`/usr/bin/rg`) and Windows
+ // `rg.exe` count as the command.
+ const commandName = token
+ .split(/[\\/]/)
+ .pop()
+ ?.replace(/\.exe$/i, '');
+ if (commandName === 'rg' || commandName === 'grep') foundCmd = true;
+ continue;
+ }
+ if (endOfOptions) {
+ return token.length >= 3 ? token : null;
+ }
+ if (token === '--') {
+ endOfOptions = true;
+ continue;
+ }
+ if (token.startsWith('-')) {
+ const attachedPattern = token.match(/^--regexp=(.+)$/) || token.match(/^-e(.+)$/);
+ if (attachedPattern) {
+ return attachedPattern[1].length >= 3 ? attachedPattern[1] : null;
+ }
+ if (flagsWithValues.has(token) || patternFlags.has(token)) {
+ skipNext = true;
+ skipNextAsPattern = patternFlags.has(token);
+ }
+ 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;
+}
+
+/**
+ * Run `gitnexus augment` for `pattern` and return its stderr — the augment CLI
+ * writes results there because LadybugDB's native module captures stdout at the
+ * OS fd level.
+ *
+ * GITNEXUS_HOOK_CLI_PATH is tried first and run as `node `, the only form
+ * that works on Windows, where Node refuses to spawn the `.cmd` shims without a
+ * shell (CVE-2024-27980). Then a PATH binary, then a version-pinned npx.
+ *
+ * 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 spawnOpts = {
+ encoding: 'utf-8',
+ timeout: 8000,
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ };
+
+ const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH;
+ if (hookCli && String(hookCli).trim() && fs.existsSync(String(hookCli))) {
+ try {
+ const child = spawnSync(process.execPath, [String(hookCli), ...args], spawnOpts);
+ if (!child.error && child.status === 0 && child.stderr && child.stderr.trim()) {
+ return child.stderr;
+ }
+ } catch {
+ /* graceful failure */
+ }
+ return '';
+ }
+
+ try {
+ const child = spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, spawnOpts);
+ if (!child.error && child.status === 0 && child.stderr && child.stderr.trim()) {
+ return child.stderr;
+ }
+ } catch {
+ /* not on PATH — fall through to npx */
+ }
+
+ try {
+ const child = spawnSync(
+ isWin ? 'npx.cmd' : 'npx',
+ ['-y', `gitnexus@${PINNED_VERSION}`, ...args],
+ spawnOpts,
+ );
+ if (!child.error && child.status === 0 && child.stderr && child.stderr.trim()) {
+ return child.stderr;
+ }
+ } catch {
+ /* graceful failure */
+ }
+
+ return '';
+}
+
+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 gitNexusDir = findGitNexusDir(cwd);
+ if (!gitNexusDir) 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;
+
+ const release = acquireHookSlot(gitNexusDir);
+ if (!release) return; // all per-repo augment slots held by concurrent sessions
+
+ let result = '';
+ try {
+ if (hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), 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 };
diff --git a/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs b/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs
new file mode 100644
index 000000000..4376795e2
--- /dev/null
+++ b/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs
@@ -0,0 +1,728 @@
+/**
+ * Cross-platform best-effort probe: does another process hold dbPath open
+ * with a command line that looks like a GitNexus MCP/serve server?
+ *
+ * Backends (no user-installed Sysinternals):
+ * - Linux: cmdline-first procfs scan under /proc, no lsof at all (#2180). Three
+ * phases, cheapest first: (0) read /proc//comm — a tiny task->comm read
+ * that never touches the target's mm — and keep only PIDs whose comm is a
+ * plausible node/gitnexus server; (1) read up to GITNEXUS_HOOK_PROC_CMDLINE_MAX
+ * bytes of /proc//cmdline via openSync+readSync (bounded, so a D-state
+ * holder stuck on mmap_lock or a giant argv can't wedge the hook) and prefilter
+ * with isGitNexusServerCommand; (2) only for the 0..N survivors, stat their
+ * /proc//fd/* and compare dev+inode against the target lbug. The lbug
+ * handle is fd-visible (a @ladybugdb/core property), so this finds every real
+ * owner without scanning every fd of every process.
+ * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
+ * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
+ * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
+ *
+ * Fail matrix:
+ * - Linux proc scan: owner found -> fail-closed (skip augment); budget exhausted
+ * (GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS) -> fail-CLOSED (#2180). This is a
+ * deliberate change from the old "timeout -> fail-open then try lsof" path.
+ * End-to-end the busy-host outcome is unchanged: the old code's lsof fallback
+ * ETIMEDOUT'd on the very hosts where the scan ran out of budget and ALSO
+ * failed closed there — the lsof leg only ever added 1-2s of dead work plus
+ * the orphan-storm risk it caused (#2163). What changes is that an overloaded
+ * host now self-throttles immediately (the throttle the incident needed)
+ * instead of paying for a doomed lsof. Mid-load hosts that used to fall
+ * through to a successful lsof now answer from the scan directly (faster) or,
+ * if even the scan can't finish in budget, fail closed (self-throttle) — a
+ * bounded, documented tradeoff, never an orphan.
+ * - macOS / other Unix: fail-open on most errors; fail-closed only on lsof
+ * ETIMEDOUT, matching the hook contract.
+ * - Windows: fail-closed only on PowerShell ETIMEDOUT.
+ *
+ * Unix subprocess containment contract (#2163):
+ * - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
+ * wrapper is found (`timeout -k 1 lsof ...`). If this hook process
+ * is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
+ * survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
+ * it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
+ * - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
+ * wrapper off deterministically; any other value is adopted only when it
+ * exists AND passes a one-shot `-k` exit-propagation self-test — otherwise
+ * resolution FALLS THROUGH to the built-in candidate list (first self-test
+ * pass wins), so no malformed value of any shape can silently disable
+ * orphan containment.
+ * - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
+ * ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
+ * A probe before that first query is therefore always false — a known,
+ * pre-existing race, not a bug in this probe.
+ * - resolveUnixGuardTimeout is exported so the hook adapters can wrap the
+ * `gitnexus augment` CLI child — the longest-lived hook subprocess (7s
+ * local / 12s npx inner budgets) — in the same guard; see runGitNexusCli
+ * in the adapters (#2163 follow-up).
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+function isGitNexusServerCommand(command) {
+ const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command);
+ const hasGitNexus =
+ /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) ||
+ /node_modules[/\\]gitnexus[/\\]/.test(command);
+ return hasServerMode && hasGitNexus;
+}
+
+// GITNEXUS_DEBUG-gated stderr diagnostics. Reuses the exact gating predicate the
+// Windows ps1-load warning already uses (===' 1' / ==='true') so there is one
+// debug convention in this file, and writes via process.stderr.write (NOT a
+// spawn) so it never perturbs the windowsHide spawn-count invariant.
+function debugLog(msg) {
+ if (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') {
+ process.stderr.write(`[GitNexus hook] ${msg}\n`);
+ }
+}
+
+function resolveHookBinary(tool) {
+ const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
+ const fromEnv = process.env[envKey];
+ if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
+ return String(fromEnv);
+ }
+ const candidates =
+ tool === 'lsof'
+ ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool]
+ : ['/bin/ps', '/usr/bin/ps', tool];
+ for (const candidate of candidates) {
+ if (candidate === tool) return tool;
+ try {
+ if (fs.existsSync(candidate)) return candidate;
+ } catch {
+ /* ignore */
+ }
+ }
+ return tool;
+}
+
+function hasMissingHookBinaryOverride(tool) {
+ const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
+ const fromEnv = process.env[envKey];
+ if (!fromEnv || !String(fromEnv).trim()) return false;
+ try {
+ return !fs.existsSync(String(fromEnv).trim());
+ } catch {
+ return true;
+ }
+}
+
+// Sentinel:
+// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
+// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
+// null = no usable wrapper (disabled, none found, or self-test failed)
+let unixGuardTimeoutCache;
+
+/**
+ * Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
+ * (#2163). Unix-only by contract: the probe's win32 dispatch returns before
+ * reaching it, and the exported callers (the adapters' runGitNexusCli,
+ * #2163 follow-up) must check the platform first — the self-test below
+ * spawns /bin/sh. The memoized result is module-wide, so probe and adapter
+ * share one lazy self-test per hook process.
+ *
+ * GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
+ * wrapper off; any other value is only a CANDIDATE — an existing file path
+ * is tried first, but it must pass the `-k` exit-propagation self-test to
+ * be adopted. On any failure (non-existent path, directory, non-executable
+ * file, wrapper without `-k` support, always-exit-0 stub, …) resolution
+ * falls through to the built-in candidates below, tried in order, first
+ * self-test pass wins. This is strictly stronger than the sibling
+ * GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only
+ * check existence): no bad env value of ANY shape can silently disable
+ * orphan containment.
+ *
+ * Lazy self-test: candidates are probed only when the lsof/ps fallback is
+ * first reached, and the result is memoized. A candidate is adopted only
+ * when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN
+ * the wrapped command AND PROPAGATE its exit status. This rejects two
+ * failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34,
+ * toybox, broken symlinks — which would exit with a usage error without
+ * ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed
+ * contract into fail-open (#1492 regression); and always-exit-0 stubs
+ * (/bin/true shapes), which would otherwise be adopted and "succeed" every
+ * wrapped spawn instantly without running it — a constant no-owner probe
+ * answer and, worse, a silently dead augment (status 0, empty stderr passes
+ * the adapters' success check with no context; #2163 follow-up review).
+ * Only when EVERY candidate fails does the probe fall back to the unwrapped
+ * status quo (memoized null). busybox ≥1.34 passes the test and is fully
+ * usable for everything THIS file spawns (lsof/ps are the guard's direct
+ * children) and for the adapters' direct-exec arm. The adapters' npx arm
+ * additionally relies on coreutils' process-GROUP signalling for its
+ * `-s KILL` grandchild reaping; busybox signals only its direct child, and
+ * this self-test deliberately does not probe that capability — see the
+ * adapter docblocks for the residual-gap statement.
+ */
+function passesGuardSelfTest(guard) {
+ try {
+ const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], {
+ encoding: 'utf-8',
+ timeout: 3000,
+ stdio: ['ignore', 'ignore', 'ignore'],
+ windowsHide: true,
+ });
+ return !selfTest.error && selfTest.status === 42;
+ } catch {
+ return false;
+ }
+}
+
+function resolveUnixGuardTimeout() {
+ if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
+ unixGuardTimeoutCache = null;
+ const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
+ const trimmed = fromEnv ? String(fromEnv).trim() : '';
+ if (trimmed === 'disabled') return unixGuardTimeoutCache;
+ const candidates = [];
+ if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
+ for (const builtin of [
+ '/usr/bin/timeout',
+ '/bin/timeout',
+ '/opt/homebrew/bin/gtimeout',
+ '/usr/local/bin/gtimeout',
+ ]) {
+ try {
+ if (fs.existsSync(builtin)) candidates.push(builtin);
+ } catch {
+ /* ignore */
+ }
+ }
+ for (const candidate of candidates) {
+ if (passesGuardSelfTest(candidate)) {
+ unixGuardTimeoutCache = candidate;
+ break;
+ }
+ }
+ return unixGuardTimeoutCache;
+}
+
+function resolveWindowsPowerShellPath() {
+ const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
+ if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
+ return String(fromEnv).trim();
+ }
+ const root = process.env.SystemRoot || 'C:\\Windows';
+ const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
+ if (fs.existsSync(ps)) return ps;
+ const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
+ if (fs.existsSync(psWow)) return psWow;
+ return 'powershell.exe';
+}
+
+// Sentinel:
+// undefined = not loaded yet (try the read)
+// string = encoded PowerShell command (successful load)
+// null = load attempted and failed (do not retry; warning already emitted)
+let windowsRmListPsEncodedCommandCache;
+let windowsRmListPsLoadFailureWarned = false;
+function getWindowsRmListEncodedCommand() {
+ if (windowsRmListPsEncodedCommandCache !== undefined) {
+ return windowsRmListPsEncodedCommandCache;
+ }
+ try {
+ const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1');
+ const src = fs
+ .readFileSync(ps1Path, 'utf8')
+ .replace(/^\uFEFF/, '')
+ .replace(/\r\n/g, '\n');
+ windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64');
+ } catch (err) {
+ windowsRmListPsEncodedCommandCache = null;
+ if (
+ !windowsRmListPsLoadFailureWarned &&
+ (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true')
+ ) {
+ windowsRmListPsLoadFailureWarned = true;
+ const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown';
+ process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`);
+ }
+ }
+ return windowsRmListPsEncodedCommandCache;
+}
+
+function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
+ const encoded = getWindowsRmListEncodedCommand();
+ if (!encoded) return false;
+ const psExe = resolveWindowsPowerShellPath();
+ const r = spawnSync(
+ psExe,
+ [
+ '-NoProfile',
+ '-NonInteractive',
+ '-ExecutionPolicy',
+ 'Bypass',
+ '-STA',
+ '-EncodedCommand',
+ encoded,
+ ],
+ {
+ encoding: 'utf-8',
+ timeout: 6000,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ windowsHide: true,
+ env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs },
+ },
+ );
+ // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment).
+ if (r.error) return r.error.code === 'ETIMEDOUT';
+ if (r.status !== 0) return false;
+ let rows;
+ try {
+ rows = JSON.parse(String(r.stdout || '').trim() || '[]');
+ } catch {
+ return false;
+ }
+ if (!Array.isArray(rows)) return false;
+ for (const row of rows) {
+ const procId = Number(row.pid);
+ const cmd = String(row.cmd || '');
+ if (!Number.isFinite(procId) || procId === myPid) continue;
+ if (isGitNexusServerCommand(cmd)) return true;
+ }
+ return false;
+}
+
+// The procfs root every Linux scan path reads from. Production is always /proc;
+// GITNEXUS_HOOK_PROC_ROOT only exists so unit tests can inject a fixture tree
+// (comm + cmdline + fd symlinks) and assert the three-phase logic without
+// scanning the real, ~hundreds-of-process /proc of the test host.
+//
+// Test-only gate (F4): the override is honored ONLY under a test runner —
+// vitest injects VITEST="true" and NODE_ENV="test" into every worker (verified;
+// a production hook is `node .cjs` with neither set). Without the gate, a
+// production env that accidentally leaked GITNEXUS_HOOK_PROC_ROOT (pointing at an
+// empty/bad tree) would make readdirSync find no pids -> 'not-owned' -> Linux
+// owner detection silently OFF (fail-OPEN: augment races the real server for the
+// lbug, the #1492 class). Gating to the test signal makes that leak inert in
+// production (always /proc) while the fake-procfs unit tests, which run under
+// vitest, still inject freely. Unset env (or non-test context) => /proc, so the
+// production path is byte-for-byte the historical behavior.
+function isTestContext() {
+ return (
+ process.env.VITEST === 'true' || process.env.VITEST === '1' || process.env.NODE_ENV === 'test'
+ );
+}
+function getProcRoot() {
+ if (!isTestContext()) return '/proc';
+ const raw = process.env.GITNEXUS_HOOK_PROC_ROOT;
+ return raw && String(raw).trim() ? String(raw) : '/proc';
+}
+
+// Max bytes read from /proc//cmdline in Phase 1. Bounded by default so a
+// D-state holder wedged on mmap_lock, or a process with a pathological multi-MB
+// argv, can't stall the hook. 16 KiB comfortably clears a realistic
+// `node mcp` line
+// (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large
+// enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for
+// tests; never goes below PROC_CMDLINE_FLOOR.
+const PROC_CMDLINE_FLOOR = 4096;
+function getCmdlineMaxBytes() {
+ const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX;
+ // Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at
+ // 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the
+ // default; trailing garbage ("8abc") now -> NaN -> default (stricter).
+ const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN;
+ if (Number.isFinite(n) && n >= PROC_CMDLINE_FLOOR) return n;
+ return 16384;
+}
+
+// Phase 0 comm prefilter. /proc//comm is the kernel task->comm string,
+// capped at 16 bytes INCLUDING the trailing NUL — i.e. at most 15 visible
+// chars, truncated by the kernel with no marker. So a process whose real name
+// is longer than 15 chars shows a 15-char prefix here. The match below is
+// therefore truncation-safe in BOTH directions (a whitelist name that is a
+// prefix of comm, or comm that is a prefix of a whitelist name, both count) to
+// guarantee we never drop a real owner at this cheap stage — Phase 2's dev+ino
+// fd check is the real authority; Phase 0/1 only exist to skip the overwhelming
+// majority (kernel threads, shells, editors) cheaply.
+//
+// The whitelist is calibrated against what a real `gitnexus mcp`/`serve` server
+// actually reports for comm. Observed on production hosts: the server renames
+// its main thread, so comm reads `MainThread` (via @ladybugdb/core's
+// worker_threads setup), NOT `node` — omitting it would blind the probe to
+// every real server (#1492-class owner miss). We also keep the plausible
+// launcher/runtime basenames in case a future build does not rename the thread.
+// Conservative by design: over-collecting a few extra candidates only costs a
+// bounded number of Phase 1 cmdline reads.
+const COMM_CANDIDATES = ['node', 'gitnexus', 'bun', 'deno', 'npm', 'npx', 'MainThread'];
+function commLooksLikeServer(comm) {
+ const c = comm.trim();
+ if (!c) return false;
+ for (const name of COMM_CANDIDATES) {
+ if (name === c || name.startsWith(c) || c.startsWith(name)) return true;
+ }
+ return false;
+}
+
+function readProcComm(procRoot, pidStr) {
+ try {
+ return fs
+ .readFileSync(path.join(procRoot, pidStr, 'comm'), 'utf8')
+ .replace(/\0+/g, '')
+ .trim();
+ } catch {
+ return '';
+ }
+}
+
+// Timeout sentinel for readLinuxCmdline (F3). MUST be distinct from the
+// "unreadable/empty" return value (''): '' flows through isGitNexusServerCommand
+// as a NON-candidate (both regexes are false on ''), so the Phase 1 caller
+// `continue`s past it — correct for a raced/openSync-failed pid, but a FAIL-OPEN
+// bug if it ever meant "I ran out of budget mid-read" (a real owner whose
+// escalation timed out would be silently dropped, racing the lbug -> #1492). A
+// unique Symbol can never collide with any cmdline string, so the caller can
+// branch on it explicitly and map a mid-read timeout to the tri-state 'timeout'
+// (fail-CLOSED) instead of swallowing it as a non-candidate.
+const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout');
+
+// Bounded /proc//cmdline read for Phase 1. openSync+readSync (not
+// readFileSync) so a D-state holder cannot stall the hook on a huge or
+// never-EOF argv: we read at most `cap` bytes and stop. cmdline separates argv
+// with NULs; convert to spaces for isGitNexusServerCommand.
+//
+// Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the
+// first path component while the `mcp`/`serve` mode token is the LAST argv, so
+// a naive 4 KB read could clip the mode token off a server launched with a very
+// long interpreter path and silently miss a real owner. We mitigate two ways:
+// (a) the default cap (16 KiB) already clears realistic lines; (b) if the first
+// read fills the cap AND already contains the `gitnexus` token but no mode
+// token yet, we keep reading in bounded chunks (up to a hard ceiling) until the
+// mode token appears or the file ends — so a genuine server is never missed for
+// want of a few more bytes, while non-candidates still pay only the initial
+// bounded read.
+//
+// Budget (F3): the escalation loop above is the one place a SINGLE pathological
+// candidate could read up to HARD_CEIL (256 KiB) before the next scan-level
+// budget check, weakening the timeout contract. `outOfBudget` (the scan's shared
+// deadline callback) is checked once per escalation iteration; on expiry we
+// return CMDLINE_TIMEOUT (NOT '') so the caller can fail-closed honestly rather
+// than mistake the partial read for a non-candidate. Reads that simply can't
+// open / error out still return '' (genuinely "not a readable candidate").
+function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) {
+ const file = path.join(procRoot, pidStr, 'cmdline');
+ let fd;
+ try {
+ fd = fs.openSync(file, 'r');
+ } catch {
+ return '';
+ }
+ try {
+ const HARD_CEIL = 262144; // 256 KiB absolute ceiling for the escalation path
+ let collected = Buffer.alloc(0);
+ let offset = 0;
+ let chunkCap = cap;
+ for (;;) {
+ // allocUnsafe is safe here: readSync fills exactly [0, bytes), only
+ // buf.subarray(0, bytes) is consumed, and Buffer.concat deep-copies that
+ // slice into `collected`, so the uninitialized tail never reaches decode.
+ const buf = Buffer.allocUnsafe(chunkCap);
+ const bytes = fs.readSync(fd, buf, 0, chunkCap, offset);
+ if (bytes <= 0) break;
+ collected = Buffer.concat([collected, buf.subarray(0, bytes)]);
+ offset += bytes;
+ const text = collected.toString('utf8').replace(/\0+/g, ' ');
+ // Stop early when we can already decide "owner": has both the gitnexus
+ // token and a mode token. Keep going only when gitnexus is present but
+ // the mode token might be just past the boundary.
+ const hasGitNexus =
+ /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(text) ||
+ /node_modules[/\\]gitnexus[/\\]/.test(text);
+ const hasMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(text);
+ if (hasMode) break; // decided (positive); isGitNexusServerCommand re-checks below
+ if (bytes < chunkCap) break; // EOF: full cmdline read, definitive
+ if (!hasGitNexus) break; // not a candidate; do not escalate the read
+ if (offset >= HARD_CEIL) break; // bounded escalation only
+ // Budget gate the escalation: a single huge-argv candidate must not burn
+ // the whole scan deadline before we re-check. Return the timeout sentinel
+ // (never '') so the caller fails closed instead of treating us as a
+ // non-candidate. The sole caller (linuxProcScanFindGitNexusServer) always
+ // passes outOfBudget, so no presence guard is needed.
+ if (outOfBudget()) return CMDLINE_TIMEOUT;
+ chunkCap = cap; // keep reading more in cap-sized chunks
+ }
+ return collected.toString('utf8').replace(/\0+/g, ' ').trim();
+ } catch {
+ return '';
+ } finally {
+ try {
+ fs.closeSync(fd);
+ } catch {
+ /* ignore */
+ }
+ }
+}
+
+function resolveLinuxProcBudgetMs() {
+ const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
+ // Gate on the STRING's emptiness, NOT the parsed number's truthiness — the
+ // old `Number(raw && trim()) ? ... : 1200` form treated "0" as falsy and
+ // silently fell back to 1200 (#2180). Use Number() (not parseInt) so "16e3"
+ // reads as 16000, not 16 (parseInt stops at 'e'). The `&& String(raw).trim()`
+ // guard is load-bearing: without it a set-but-empty/whitespace value would be
+ // `Number("")===0` => budget 0 => immediate fail-CLOSED timeout (augment
+ // permanently skipped). With it, ''/whitespace => NaN => 1200 default, while a
+ // finite "0" still parses to an explicit, deterministic "no budget" =>
+ // immediate timeout. Non-numeric / unset => default 1200.
+ const n = raw != null && String(raw).trim() ? Number(String(raw).trim()) : NaN;
+ if (!Number.isFinite(n)) return 1200;
+ return n; // may be <= 0, meaning "out of budget on the first check"
+}
+
+// Returns one of: 'owned' (a non-self process with a GitNexus-server cmdline
+// holds the target lbug fd), 'not-owned' (scan completed, no such owner), or
+// 'timeout' (the per-scan budget was exhausted before a verdict). The name is
+// pinned by a source-contract test; only the return TYPE changed (#2180:
+// boolean -> tri-state, so the dispatcher can fail-closed on 'timeout').
+function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
+ const budget = resolveLinuxProcBudgetMs();
+ // A non-positive budget is an explicit, deterministic "no time to scan" =>
+ // immediate timeout (the #2180 test vector, and the only correct reading of
+ // the fixed parse: "0" must NOT mean 1200). Returning before any procfs read
+ // keeps it instantaneous regardless of host load.
+ if (budget <= 0) return 'timeout';
+ const procRoot = getProcRoot();
+ const cmdlineCap = getCmdlineMaxBytes();
+ const start = Date.now();
+ const outOfBudget = () => Date.now() - start > budget;
+
+ let targetStat;
+ try {
+ targetStat = fs.statSync(dbPathAbs);
+ } catch {
+ // Caller already existsSync'd the path; a stat failure here is a transient
+ // race, treat as no owner (historical semantics).
+ return 'not-owned';
+ }
+
+ let procEntries;
+ try {
+ procEntries = fs.readdirSync(procRoot, { withFileTypes: true });
+ } catch {
+ return 'not-owned';
+ }
+
+ // Phase 0 + Phase 1: collect the few PIDs whose comm AND cmdline look like a
+ // GitNexus server, without touching any fd yet.
+ const candidates = [];
+ for (const ent of procEntries) {
+ if (outOfBudget()) return 'timeout';
+ if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
+ const pid = Number.parseInt(ent.name, 10);
+ if (!Number.isFinite(pid) || pid === myPid) continue;
+
+ // Phase 0: cheap comm prefilter.
+ const comm = readProcComm(procRoot, ent.name);
+ if (!comm) continue; // unreadable comm (kernel thread, raced exit) -> skip
+ if (!commLooksLikeServer(comm)) continue;
+
+ // Phase 1: bounded cmdline read + isGitNexusServerCommand prefilter.
+ if (outOfBudget()) return 'timeout';
+ const cmdline = readLinuxCmdline(procRoot, ent.name, cmdlineCap, outOfBudget);
+ // F3: a mid-read budget timeout returns the CMDLINE_TIMEOUT sentinel (a
+ // Symbol, never a string). Fail CLOSED on it rather than letting it fall
+ // through isGitNexusServerCommand as a non-candidate — a real owner whose
+ // escalation timed out must not be silently dropped (would fail-OPEN).
+ if (cmdline === CMDLINE_TIMEOUT) return 'timeout';
+ if (!isGitNexusServerCommand(cmdline)) continue;
+ candidates.push(ent.name);
+ }
+
+ // Phase 2: only now stat the fds of the (typically 0-2) survivors.
+ for (const pidStr of candidates) {
+ if (outOfBudget()) return 'timeout';
+ const fdDir = path.join(procRoot, pidStr, 'fd');
+ let fds;
+ try {
+ fds = fs.readdirSync(fdDir);
+ } catch (err) {
+ // F1: the old code returned 'owned' for EVERY non-ENOENT error. That was
+ // a correctness bug: /proc//fd is owner-only (mode 0500), so a
+ // cross-user/root `gitnexus mcp` serving a DIFFERENT repo passes Phase 0+1
+ // (its cmdline matches) and then EACCES'es here — yet its dev+ino was
+ // NEVER compared against THIS lbug. Claiming 'owned' lets it permanently,
+ // silently suppress augment for a repo it does not actually lock. We now
+ // distinguish the failure shapes (all still fail-closed where we can't
+ // prove non-ownership, but 'timeout' is the HONEST verdict for
+ // "inconclusive", not the false-positive 'owned'):
+ const code = err && err.code;
+ if (code === 'ENOENT') {
+ // Process raced away between the candidate scan and now -> genuinely no
+ // longer an owner. Move on.
+ continue;
+ }
+ if (code === 'EACCES' || code === 'EPERM') {
+ // Permission-denied fd dir: cannot read fds, so ownership is
+ // UNVERIFIABLE. Fail closed honestly via 'timeout' (the dispatcher maps
+ // timeout -> true, same protective skip as before) WITHOUT lying that we
+ // confirmed ownership. Do NOT degrade to not-owned/fail-open: if this
+ // really is the owner, fail-open re-opens the #1492 lbug race; augment
+ // is optional context, so a conservative skip costs little.
+ debugLog(
+ `fd dir unreadable for candidate pid ${pidStr} (${code}); ownership ` +
+ `unverifiable, probe inconclusive -> fail-closed (timeout)`,
+ );
+ return 'timeout';
+ }
+ if (code === 'EIO' || code === 'ESTALE') {
+ // Genuine transient I/O against this candidate's fd dir — not evidence
+ // it does NOT hold the lbug. Treat as inconclusive and fail closed
+ // (timeout) rather than continue, so a real owner mid-I/O-blip is not
+ // dropped (would fail-open).
+ debugLog(
+ `fd dir transient I/O error for candidate pid ${pidStr} (${code}); ` +
+ `probe inconclusive -> fail-closed (timeout)`,
+ );
+ return 'timeout';
+ }
+ // Any other shape (ENOTDIR — fd path is not a directory at all, so this
+ // is not a plausible live-procfs owner — and the long tail) is treated as
+ // "this candidate is not an owner": move to the next candidate instead of
+ // the old blanket 'owned'. If no other candidate owns the lbug the scan
+ // ends not-owned (dispatcher fail-open) — acceptable because ENOTDIR means
+ // the fd entry is structurally not a real /proc//fd.
+ debugLog(
+ `fd dir not a readable directory for candidate pid ${pidStr} ` +
+ `(${code || 'unknown'}); treating candidate as non-owner -> continue`,
+ );
+ continue;
+ }
+ for (const fd of fds) {
+ if (outOfBudget()) return 'timeout';
+ try {
+ const st = fs.statSync(path.join(fdDir, fd));
+ if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
+ return 'owned';
+ }
+ } catch {
+ /* fd raced closed; ignore */
+ }
+ }
+ }
+
+ return 'not-owned';
+}
+
+function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
+ const guard = resolveUnixGuardTimeout();
+ // An explicit missing override models ENOENT and must fail open instead of
+ // falling through to a host binary with different process-table visibility.
+ if (hasMissingHookBinaryOverride('lsof')) return false;
+ const lsofPath = resolveHookBinary('lsof');
+ // The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
+ // SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
+ // SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
+ // fail-closed contract are untouched. The wrapper only matters once this
+ // hook process has been SIGKILLed and can no longer deliver that SIGTERM.
+ const [lsofCmd, lsofArgs] = guard
+ ? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
+ : [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
+ const lsof = spawnSync(lsofCmd, lsofArgs, {
+ encoding: 'utf-8',
+ timeout: 1000,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ windowsHide: true,
+ });
+ if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
+ // Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
+ // result shapes, verified against coreutils 9.1:
+ // - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
+ // SELF-RAISES the signal, so spawnSync reports {status: null, signal}
+ // with no .error (spawnSync's own ETIMEDOUT was handled above). The
+ // same shape appears when this hook is frozen >2s (SIGSTOP, laptop
+ // suspend) and the guard expires while it sleeps. By construction, a
+ // guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
+ // is a budget/kill outcome.
+ // - 124: budget expired and the child exited after the plain SIGTERM.
+ // - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
+ // or a child SIGKILLed externally (e.g. the OOM killer).
+ if (guard && lsof.status === null && lsof.signal) return true;
+ if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
+
+ const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
+ const psMissing = hasMissingHookBinaryOverride('ps');
+ const psPath = resolveHookBinary('ps');
+ for (const pid of pids) {
+ if (Number(pid) === myPid) continue;
+ // Missing ps means we cannot verify that this pid is a GitNexus server.
+ if (psMissing) continue;
+ const [psCmd, psArgs] = guard
+ ? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
+ : [psPath, ['-p', pid, '-o', 'command=']];
+ const ps = spawnSync(psCmd, psArgs, {
+ encoding: 'utf-8',
+ timeout: 500,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ windowsHide: true,
+ });
+ if (ps.error) {
+ if (ps.error.code === 'ETIMEDOUT') return true;
+ continue;
+ }
+ // Same guard-mediated-death mapping as the lsof call above (signal-death
+ // from the -k escalation or a frozen hook; 124 budget expiry; 137 only
+ // for exit-code-propagating wrappers / external SIGKILL).
+ if (guard && ps.status === null && ps.signal) return true;
+ if (guard && (ps.status === 124 || ps.status === 137)) return true;
+ if (isGitNexusServerCommand(ps.stdout || '')) return true;
+ }
+ return false;
+}
+
+/**
+ * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug).
+ * @param {number} myPid Current process PID (hook runner), excluded from matches.
+ */
+function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
+ if (!fs.existsSync(dbPath)) return false;
+ const dbPathAbs = path.resolve(dbPath);
+
+ if (process.platform === 'win32') {
+ return hasGitNexusServerOwnerWindows(dbPathAbs, myPid);
+ }
+
+ if (process.platform === 'linux') {
+ // #2180: cmdline-first procfs scan, no lsof. 'timeout' fails CLOSED
+ // (overloaded host self-throttles — the throttle the orphan-storm incident
+ // needed; the old lsof fallback ETIMEDOUT'd and failed closed on these same
+ // hosts anyway, only slower and with the orphan risk). 'not-owned' is the
+ // only false. See the fail matrix in the file header.
+ const verdict = linuxProcScanFindGitNexusServer(dbPathAbs, myPid);
+ return verdict !== 'not-owned';
+ }
+
+ return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
+}
+
+module.exports = {
+ hasGitNexusDbLockedByGitNexusServer,
+ // Exported for white-box unit tests that must assert the tri-state verdict
+ // ('owned' | 'not-owned' | 'timeout') directly — the dispatcher collapses
+ // timeout and owned to the same boolean true, so the boolean API alone cannot
+ // distinguish the F1 EACCES->timeout fix from the old EACCES->owned bug. The
+ // Probe interface already declares this optional. Linux-only by contract; the
+ // name is pinned by a source-contract test.
+ linuxProcScanFindGitNexusServer,
+ // #2163 follow-up: the hook adapters wrap the augment CLI in the same
+ // guard. Returns a self-tested wrapper path — the built-in candidates are
+ // always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
+ // exact string that passed the self-test. Same string is also the same
+ // RESOLUTION for absolute paths and for slashless names (PATH lookup is
+ // cwd-independent); a slash-containing RELATIVE override, however, is
+ // existsSync-checked and self-tested against this process's cwd while the
+ // adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
+ // a value can pass here yet ENOENT at the augment call site — set the
+ // override to an absolute path. Returns null when the wrapper is
+ // disabled/unavailable. Never call on win32 (see its JSDoc).
+ resolveUnixGuardTimeout,
+ // Exported for white-box unit tests of the numeric-env parsing (#2183 review):
+ // Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace
+ // guard that keeps a set-but-empty budget on the 1200 default instead of an
+ // immediate fail-closed timeout. Tested directly because the values are
+ // otherwise only observable indirectly through scan timing/escalation.
+ getCmdlineMaxBytes,
+ resolveLinuxProcBudgetMs,
+};
diff --git a/gitnexus-factory-plugin/hooks/hook-lock.js b/gitnexus-factory-plugin/hooks/hook-lock.js
new file mode 100644
index 000000000..759856384
--- /dev/null
+++ b/gitnexus-factory-plugin/hooks/hook-lock.js
@@ -0,0 +1,119 @@
+const fs = require('fs');
+const path = require('path');
+
+const HOOK_LOCK_SUBDIR = '.hook-locks';
+const HOOK_LOCK_MAX_INFLIGHT = 3;
+const HOOK_LOCK_STALE_MS = 30000;
+
+function acquireHookSlot(gitNexusDir) {
+ const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
+ try {
+ fs.mkdirSync(lockDir, { recursive: true });
+ } catch {
+ // Cannot create lock dir (read-only fs, cross-user perm denial, out of
+ // inodes, etc.) — fail closed by returning null. Caller skips augment.
+ // Fail-open here would let N concurrent hooks all proceed unguarded and
+ // reintroduce the #1486 fan-out the guard exists to prevent.
+ return null;
+ }
+
+ const myPidStr = String(process.pid);
+
+ for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
+ const slotPath = path.join(lockDir, `slot-${slot}.lock`);
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
+ let released = false;
+ const release = () => {
+ if (released) return;
+ released = true;
+ try {
+ // Only unlink if we still own the slot. If we appeared stale and
+ // another hook took over, the file now belongs to it — leave alone.
+ const content = fs.readFileSync(slotPath, 'utf-8').trim();
+ if (content === myPidStr) fs.unlinkSync(slotPath);
+ } catch {
+ /* already removed or unreadable */
+ }
+ };
+ process.on('exit', release);
+ return release;
+ } catch {
+ // Slot exists. Decide whether to take it over.
+ // Open once and inspect mtime + content via the same fd so there's
+ // no TOCTOU between the metadata check and the content read
+ // (codeql js/file-system-race).
+ let fd;
+ try {
+ fd = fs.openSync(slotPath, 'r');
+ } catch {
+ continue; // Vanished between EEXIST and open — retry this slot.
+ }
+ let isLive = false;
+ let mtimeMs = Date.now();
+ try {
+ mtimeMs = fs.fstatSync(fd).mtimeMs;
+ const buf = Buffer.alloc(32);
+ const n = fs.readSync(fd, buf, 0, 32, 0);
+ const ownerStr = buf.slice(0, n).toString('utf-8').trim();
+ if (ownerStr === '') {
+ // Owner created the file but hasn't written its PID yet. The
+ // wx open+write window is microseconds; give it the benefit
+ // of the doubt and treat as live.
+ isLive = true;
+ } else {
+ const owner = Number.parseInt(ownerStr, 10);
+ if (Number.isFinite(owner) && owner > 0) {
+ try {
+ process.kill(owner, 0);
+ isLive = true;
+ } catch (e) {
+ // ESRCH = process gone → treat as dead. EPERM = process exists
+ // but owned by another user (cross-user lock dir) → still alive,
+ // keep the slot. Anything else: be conservative, assume alive.
+ if (e && e.code === 'ESRCH') {
+ isLive = false;
+ } else {
+ isLive = true;
+ }
+ }
+ }
+ }
+ } catch {
+ /* unreadable — treat as dead */
+ } finally {
+ try {
+ fs.closeSync(fd);
+ } catch {
+ /* already closed */
+ }
+ }
+ // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
+ // a slow-but-alive hook is never wrongly evicted. For older slots,
+ // age is the final arbiter as a defense against PID reuse on long-
+ // abandoned slots. 30s >> the 7s augment timeout, so a healthy run
+ // never crosses this threshold.
+ if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
+ isLive = false;
+ }
+ if (isLive) break; // Try the next slot.
+ try {
+ fs.unlinkSync(slotPath);
+ } catch {
+ /* another hook beat us to it — retry will hit EEXIST */
+ }
+ // Loop and retry this slot.
+ }
+ }
+ }
+
+ return null;
+}
+
+module.exports = {
+ HOOK_LOCK_SUBDIR,
+ HOOK_LOCK_MAX_INFLIGHT,
+ HOOK_LOCK_STALE_MS,
+ acquireHookSlot,
+};
diff --git a/gitnexus-factory-plugin/hooks/hooks.json b/gitnexus-factory-plugin/hooks/hooks.json
new file mode 100644
index 000000000..4659a18e5
--- /dev/null
+++ b/gitnexus-factory-plugin/hooks/hooks.json
@@ -0,0 +1,14 @@
+{
+ "PostToolUse": [
+ {
+ "matcher": "Grep|Glob|Execute",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node ${DROID_PLUGIN_ROOT}/hooks/gitnexus-hook.js",
+ "timeout": 10
+ }
+ ]
+ }
+ ]
+}
diff --git a/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1 b/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1
new file mode 100644
index 000000000..5c1564e30
--- /dev/null
+++ b/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1
@@ -0,0 +1,76 @@
+$ErrorActionPreference = 'Stop'
+$target = $env:GITNEXUS_HOOK_RM_TARGET
+if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 }
+$target = (Resolve-Path -LiteralPath $target).ProviderPath
+
+if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) {
+Add-Type @'
+using System;
+using System.Runtime.InteropServices;
+namespace GitNexusHookRm {
+ public static class Native {
+ public const int ErrorMoreData = 234;
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
+ public struct RM_UNIQUE_PROCESS {
+ public int dwProcessId;
+ public long ProcessStartTime;
+ }
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ public struct RM_PROCESS_INFO {
+ public RM_UNIQUE_PROCESS Process;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
+ public string strAppName;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
+ public string strServiceShortName;
+ public uint ApplicationType;
+ public uint AppStatus;
+ public uint TSSessionId;
+ public uint bRestartable;
+ }
+ [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
+ public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey);
+ [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
+ public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames);
+ [DllImport("rstrtmgr.dll")]
+ public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons);
+ [DllImport("rstrtmgr.dll")]
+ public static extern int RmEndSession(uint pSessionHandle);
+ }
+}
+'@
+}
+
+$h = [uint32]0
+$key = [guid]::NewGuid().ToString('N')
+$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key)
+if ($rmErr -ne 0) { Write-Output '[]'; exit 0 }
+$files = @($target)
+$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null)
+if ($err -ne 0) {
+ [void][GitNexusHookRm.Native]::RmEndSession($h)
+ Write-Output '[]'
+ exit 0
+}
+$need = [uint32]0
+$n = [uint32]0
+$reboot = [uint32]0
+$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot)
+if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) {
+ [void][GitNexusHookRm.Native]::RmEndSession($h)
+ Write-Output '[]'
+ exit 0
+}
+$n = $need
+$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n)
+$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot)
+[void][GitNexusHookRm.Native]::RmEndSession($h)
+if ($err -ne 0) { Write-Output '[]'; exit 0 }
+
+$out = @()
+for ($i = 0; $i -lt [int]$n; $i++) {
+ $procId = $buf[$i].Process.dwProcessId
+ $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue
+ $cmd = if ($p) { $p.CommandLine } else { '' }
+ $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd }
+}
+ConvertTo-Json -InputObject @($out) -Compress
diff --git a/gitnexus-factory-plugin/mcp.json b/gitnexus-factory-plugin/mcp.json
new file mode 100644
index 000000000..4fe6590dd
--- /dev/null
+++ b/gitnexus-factory-plugin/mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "gitnexus": {
+ "command": "npx",
+ "args": ["-y", "gitnexus@1.6.9", "mcp"]
+ }
+ }
+}
diff --git a/gitnexus/README.md b/gitnexus/README.md
index f3f870641..6c38f3357 100644
--- a/gitnexus/README.md
+++ b/gitnexus/README.md
@@ -2,7 +2,7 @@
**Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
-Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
+Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Factory** (Droid), **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
[](https://www.npmjs.com/package/gitnexus)
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
@@ -44,12 +44,13 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** |
+| **Factory** (Droid) | Yes | Yes | Yes (PostToolUse, [plugin](../gitnexus-factory-plugin/)) | **Full** |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
-> **Claude Code** and **Codex** get the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
+> **Full** means MCP tools + agent skills + hooks that enrich searches with graph context. **Claude Code** and **Codex** go deepest: their PreToolUse hooks enrich the search before it runs, and their PostToolUse hooks also detect a stale index after commits and prompt the agent to reindex. **Cursor**, **Antigravity**, and **Factory** augment from a post-tool hook only, so they enrich the result rather than the query and do not carry the stale-index hint.
### Community Integrations
@@ -88,6 +89,33 @@ codex plugin marketplace add abhigyanpatwari/GitNexus
> **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call.
+### Factory (Droid) (full support — MCP + skills + hooks)
+
+`gitnexus setup -c droid` writes the MCP server to `~/.factory/mcp.json` and installs skills to
+`~/.factory/skills/`. To configure MCP by hand instead, add to `~/.factory/mcp.json`
+([user scope](https://docs.factory.ai/cli/configuration/mcp) — applies to all projects):
+
+```json
+{
+ "mcpServers": {
+ "gitnexus": {
+ "command": "npx",
+ "args": ["-y", "gitnexus@latest", "mcp"]
+ }
+ }
+}
+```
+
+For the PostToolUse search-augment hook, install the bundled
+[`gitnexus-factory-plugin/`](../gitnexus-factory-plugin/) with `droid plugin install gitnexus@`
+from a marketplace that includes this repo, or point Droid at it via `extraKnownMarketplaces` in
+`.factory/settings.json`.
+
+> **Factory notes:** Droid reads [AGENTS.md natively](https://docs.factory.ai/), which already carries the
+> GitNexus context block, so no SessionStart hook is registered. Pick **one** install route
+> (`gitnexus setup -c droid` **or** the plugin) — the plugin ships its own MCP entry, so installing both
+> can register the server twice.
+
### Cursor / Windsurf
Add to `~/.cursor/mcp.json` (global — works for all projects):
diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts
index 79169214b..eb3eb64af 100644
--- a/gitnexus/scripts/cross-platform-tests.ts
+++ b/gitnexus/scripts/cross-platform-tests.ts
@@ -82,6 +82,7 @@ const PLATFORM_LOGIC = [
'test/unit/hooks.test.ts',
'test/unit/hook-db-lock-probe.test.ts',
'test/unit/cursor-hook.test.ts',
+ 'test/unit/factory-plugin.test.ts',
'test/unit/sidecar-recovery.test.ts',
'test/unit/pool-wal-recovery.test.ts',
'test/unit/lbug-adapter-wal-schema.test.ts',
diff --git a/gitnexus/scripts/sync-plugin-manifests.mjs b/gitnexus/scripts/sync-plugin-manifests.mjs
index 5bcc8f29c..3858cfd06 100644
--- a/gitnexus/scripts/sync-plugin-manifests.mjs
+++ b/gitnexus/scripts/sync-plugin-manifests.mjs
@@ -13,6 +13,9 @@
* - gitnexus-claude-plugin/.codex-plugin/plugin.json (top-level version)
* - .agents/plugins/marketplace.json (plugins[gitnexus])
* - gitnexus-claude-plugin/skills//mcp.json (gitnexus@ launch arg, x10)
+ * - gitnexus-factory-plugin/.factory-plugin/plugin.json (top-level version)
+ * - gitnexus-factory-plugin/mcp.json (gitnexus@ launch arg)
+ * - .factory-plugin/marketplace.json (plugins[gitnexus])
*
* Modes:
* node scripts/sync-plugin-manifests.mjs rewrite stale surfaces
@@ -54,6 +57,16 @@ const MANIFEST_SURFACES = [
file: `gitnexus-claude-plugin/skills/${name}/mcp.json`,
kind: 'mcp',
})),
+ // The Factory plugin's manifest is also its pin source at runtime: the
+ // PostToolUse hook reads this version to build its `npx -y gitnexus@`
+ // fallback, so stamping it here keeps the hook and the MCP entry on the same
+ // released CLI.
+ { file: 'gitnexus-factory-plugin/.factory-plugin/plugin.json', kind: 'plugin' },
+ { file: 'gitnexus-factory-plugin/mcp.json', kind: 'mcp' },
+ // Droid reads .factory-plugin/marketplace.json before .claude-plugin's, so
+ // this entry is what makes `droid plugin install` deliver the Factory plugin
+ // (Execute matcher, pinned mcp.json) instead of the translated Claude one.
+ { file: '.factory-plugin/marketplace.json', kind: 'marketplace' },
];
const PLUGIN_NAME = 'gitnexus';
diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts
index a1bf1841b..212a4c400 100644
--- a/gitnexus/src/cli/editor-targets.ts
+++ b/gitnexus/src/cli/editor-targets.ts
@@ -26,7 +26,8 @@ export type EditorId =
| 'opencode'
| 'codebuddy'
| 'qoder'
- | 'codex';
+ | 'codex'
+ | 'droid';
/** An editor whose MCP config is a JSONC document (server keyed by name). */
export interface McpJsoncTarget {
@@ -151,6 +152,15 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
file: path.join(home, '.qoder.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
+ {
+ id: 'droid',
+ label: 'Factory Droid',
+ // Factory's user-scope MCP config (https://docs.factory.ai/cli/configuration/mcp).
+ // Same `mcpServers` object shape as Cursor/Claude; user config takes
+ // precedence over project-level .factory/mcp.json.
+ file: path.join(home, '.factory', 'mcp.json'),
+ keyPath: ['mcpServers', 'gitnexus'],
+ },
];
const codex: CodexMcpTarget = {
@@ -175,6 +185,9 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
{ id: 'qoder', label: 'Qoder', dir: path.join(home, '.qoder', 'skills') },
// Codex reads skills from ~/.agents/skills (not ~/.codex).
{ id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
+ // Factory Droid reads user-scope skills from ~/.factory/skills/{name}/SKILL.md
+ // (https://docs.factory.ai/cli/configuration/skills).
+ { id: 'droid', label: 'Factory Droid', dir: path.join(home, '.factory', 'skills') },
];
const hooks: HookTarget[] = [
diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts
index 70238dc00..ec952dfe5 100644
--- a/gitnexus/src/cli/index.ts
+++ b/gitnexus/src/cli/index.ts
@@ -28,7 +28,7 @@ program.name('gitnexus').description('GitNexus local CLI and MCP server').versio
program
.command('setup')
.description(
- 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
+ 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid',
)
.option(
'-c, --coding-agent ',
diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts
index 7ca83643c..988bd8f09 100644
--- a/gitnexus/src/cli/setup.ts
+++ b/gitnexus/src/cli/setup.ts
@@ -96,6 +96,7 @@ const CODING_AGENT_IDS = {
codebuddy: 'codebuddy',
qoder: 'qoder',
codex: 'codex',
+ droid: 'droid',
} as const satisfies Record;
const SUPPORTED_CODING_AGENTS = Object.values(CODING_AGENT_IDS);
@@ -953,6 +954,59 @@ async function installQoderSkills(result: SetupResult): Promise {
}
}
+/**
+ * Configure the GitNexus MCP server for Factory Droid.
+ *
+ * Factory stores user-scope MCP config in ~/.factory/mcp.json using the same
+ * `{ mcpServers: { : {...} } }` JSONC shape as Cursor/Claude
+ * (https://docs.factory.ai/cli/configuration/mcp), so we reuse mergeJsoncFile
+ * rather than shelling out to `droid mcp add` — that keeps setup working even
+ * when the `droid` binary isn't on PATH.
+ */
+async function setupDroid(result: SetupResult): Promise {
+ const factoryDir = path.join(os.homedir(), '.factory');
+ if (!(await dirExists(factoryDir))) {
+ result.skipped.push('Factory Droid (not installed)');
+ return;
+ }
+
+ const { file: mcpPath, keyPath } = mcpTarget('droid');
+ try {
+ const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
+ if (ok) {
+ result.configured.push('Factory Droid');
+ } else {
+ result.errors.push(
+ 'Factory Droid: mcp.json is corrupt — skipping to preserve existing content',
+ );
+ }
+ } catch (err: any) {
+ result.errors.push(`Factory Droid: ${err.message}`);
+ }
+}
+
+/**
+ * Install global Factory Droid skills to ~/.factory/skills/{name}/SKILL.md
+ * (https://docs.factory.ai/cli/configuration/skills — same SKILL.md layout as
+ * Claude Code).
+ */
+async function installDroidSkills(result: SetupResult): Promise {
+ const factoryDir = path.join(os.homedir(), '.factory');
+ if (!(await dirExists(factoryDir))) return;
+
+ const skillsDir = skillTarget('droid').dir;
+ try {
+ const installed = await installSkillsTo(skillsDir);
+ if (installed.length > 0) {
+ result.configured.push(
+ `Factory Droid skills (${installed.length} skills → ~/.factory/skills/)`,
+ );
+ }
+ } catch (err: any) {
+ result.errors.push(`Factory Droid skills: ${err.message}`);
+ }
+}
+
/**
* Build a TOML section for Codex MCP config (~/.codex/config.toml).
*/
@@ -1254,6 +1308,7 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string }
if (selected.has('codebuddy')) await setupCodeBuddy(result);
if (selected.has('qoder')) await setupQoder(result);
if (selected.has('codex')) await setupCodex(result);
+ if (selected.has('droid')) await setupDroid(result);
// Install global skills for platforms that support them
if (selected.has('claude')) {
@@ -1272,6 +1327,10 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string }
await installCodexSkills(result);
await installClaudeSchemaHooks(result, 'codex');
}
+ // MCP + skills only. Factory hooks (Execute matcher, PostToolUse) ship in the
+ // standalone gitnexus-factory-plugin instead of the setup path — add a droid
+ // hook target + adapter here if setup-installed hooks are wanted.
+ if (selected.has('droid')) await installDroidSkills(result);
// Print results
if (result.configured.length > 0) {
diff --git a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
index b72774372..100b1007a 100644
--- a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
+++ b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
@@ -82,7 +82,7 @@ describe('setup → uninstall round-trip', () => {
process.env.USERPROFILE = tempHome;
// Mark every editor as "installed" so setup configures all of them.
- for (const dir of ['.cursor', '.claude', '.codex', '.codebuddy', '.qoder']) {
+ for (const dir of ['.cursor', '.claude', '.codex', '.codebuddy', '.qoder', '.factory']) {
await fs.mkdir(path.join(tempHome, dir), { recursive: true });
}
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
diff --git a/gitnexus/test/unit/factory-plugin.test.ts b/gitnexus/test/unit/factory-plugin.test.ts
new file mode 100644
index 000000000..a38432592
--- /dev/null
+++ b/gitnexus/test/unit/factory-plugin.test.ts
@@ -0,0 +1,407 @@
+/**
+ * Tests: GitNexus Factory AI (Droid) plugin
+ *
+ * Covers the standalone `gitnexus-factory-plugin/` used by `droid plugin
+ * install`:
+ * - manifest + hook wiring (plugin.json / mcp.json / hooks.json)
+ * - the PostToolUse search-augment hook's guard reuse and early-exit behavior
+ * - a drift guard proving the bundled guard modules are byte-identical to the
+ * canonical Claude-adapter copies (so a fix to one can't silently skip the
+ * other)
+ *
+ * The augment fan-out guard (acquireHookSlot) and the LadybugDB owner probe are
+ * the exact modules the Claude/Codex adapter ships; their internals are covered
+ * by hooks.test.ts and hook-db-lock-probe.test.ts. Here we assert the Factory
+ * hook WIRES them and behaves correctly on the Factory-specific paths.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { spawnSync } from 'child_process';
+import { createRequire } from 'module';
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import {
+ runHook,
+ parseHookOutput,
+ createHookToolDir,
+ hookEnv,
+} from '../utils/hook-test-helpers.js';
+
+const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
+const PLUGIN_DIR = path.join(REPO_ROOT, 'gitnexus-factory-plugin');
+const HOOK = path.join(PLUGIN_DIR, 'hooks', 'gitnexus-hook.js');
+const HOOKS_JSON = path.join(PLUGIN_DIR, 'hooks', 'hooks.json');
+const PLUGIN_JSON = path.join(PLUGIN_DIR, '.factory-plugin', 'plugin.json');
+const MCP_JSON = path.join(PLUGIN_DIR, 'mcp.json');
+const CLAUDE_HOOKS = path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'hooks');
+
+// Guard modules bundled into the Factory plugin, kept byte-identical to the
+// canonical Claude-adapter copies.
+const BUNDLED_GUARDS = ['hook-lock.js', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1'] as const;
+
+const require_ = createRequire(import.meta.url);
+const { parseRgGrepPattern } = require_(HOOK) as {
+ parseRgGrepPattern: (command: string) => string | null;
+};
+
+// ─── Manifest / file presence ───────────────────────────────────────
+
+describe('Factory plugin files', () => {
+ it('ships the hook, its guards, and both manifests', () => {
+ for (const p of [
+ HOOK,
+ HOOKS_JSON,
+ PLUGIN_JSON,
+ MCP_JSON,
+ ...BUNDLED_GUARDS.map((f) => path.join(PLUGIN_DIR, 'hooks', f)),
+ ]) {
+ expect(fs.existsSync(p), `${p} should exist`).toBe(true);
+ }
+ });
+
+ it('.factory-plugin holds only plugin.json (Factory manifest contract)', () => {
+ expect(fs.readdirSync(path.join(PLUGIN_DIR, '.factory-plugin'))).toEqual(['plugin.json']);
+ });
+});
+
+// ─── Marketplace wiring ─────────────────────────────────────────────
+
+// Droid reads .factory-plugin/marketplace.json before .claude-plugin's, so this
+// is what makes `droid plugin install` deliver this Factory plugin instead of
+// the translated Claude one (Bash matcher, gitnexus@latest MCP).
+describe('Factory marketplace wiring', () => {
+ const marketplace = JSON.parse(
+ fs.readFileSync(path.join(REPO_ROOT, '.factory-plugin', 'marketplace.json'), 'utf-8'),
+ );
+
+ it('exposes a single gitnexus plugin sourced from ./gitnexus-factory-plugin', () => {
+ const entries = marketplace.plugins.filter((p: { name: string }) => p.name === 'gitnexus');
+ expect(entries).toHaveLength(1);
+ expect(entries[0].source).toBe('./gitnexus-factory-plugin');
+ });
+});
+
+// ─── Drift guard: bundled guards === canonical Claude copies ─────────
+
+describe('Factory plugin bundled guards stay in lockstep with the Claude adapter', () => {
+ for (const f of BUNDLED_GUARDS) {
+ it(`${f} is byte-identical to the canonical copy`, () => {
+ const bundled = fs.readFileSync(path.join(PLUGIN_DIR, 'hooks', f));
+ const canonical = fs.readFileSync(path.join(CLAUDE_HOOKS, f));
+ expect(bundled.equals(canonical)).toBe(true);
+ });
+ }
+});
+
+// ─── plugin.json ────────────────────────────────────────────────────
+
+describe('Factory plugin.json', () => {
+ const manifest = JSON.parse(fs.readFileSync(PLUGIN_JSON, 'utf-8'));
+
+ it('is named gitnexus', () => {
+ expect(manifest.name).toBe('gitnexus');
+ });
+
+ it('version matches gitnexus/package.json (single source of truth)', () => {
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(REPO_ROOT, 'gitnexus', 'package.json'), 'utf-8'),
+ );
+ expect(manifest.version).toBe(pkg.version);
+ });
+});
+
+// ─── mcp.json ───────────────────────────────────────────────────────
+
+describe('Factory mcp.json', () => {
+ const mcp = JSON.parse(fs.readFileSync(MCP_JSON, 'utf-8'));
+
+ it('registers the gitnexus MCP server under mcpServers', () => {
+ expect(mcp.mcpServers?.gitnexus?.command).toBe('npx');
+ expect(mcp.mcpServers.gitnexus.args).toContain('mcp');
+ });
+
+ // Executed state, not quickstart docs: `@latest` would let a future registry
+ // upload run on MCP connect without a plugin revision.
+ it('pins the CLI to the released version instead of a mutable tag', () => {
+ const pkg = JSON.parse(
+ fs.readFileSync(path.join(REPO_ROOT, 'gitnexus', 'package.json'), 'utf-8'),
+ );
+ expect(mcp.mcpServers.gitnexus.args).toContain(`gitnexus@${pkg.version}`);
+ expect(JSON.stringify(mcp)).not.toContain('gitnexus@latest');
+ });
+});
+
+// ─── hooks.json wiring ──────────────────────────────────────────────
+
+describe('Factory hooks.json wiring', () => {
+ const manifest = JSON.parse(fs.readFileSync(HOOKS_JSON, 'utf-8'));
+ const entry = manifest.PostToolUse[0];
+
+ it('registers a PostToolUse hook', () => {
+ expect(Array.isArray(manifest.PostToolUse)).toBe(true);
+ });
+
+ it('matches Factory search tools (Grep, Glob, Execute — not Bash)', () => {
+ expect(entry.matcher).toBe('Grep|Glob|Execute');
+ expect(entry.matcher).not.toMatch(/\bBash\b/);
+ });
+
+ it('invokes the hook via the ${DROID_PLUGIN_ROOT} plugin-root variable', () => {
+ const command: string = entry.hooks[0].command;
+ expect(command).toContain('${DROID_PLUGIN_ROOT}');
+ expect(command).toContain('hooks/gitnexus-hook.js');
+ });
+
+ it('declares timeout in seconds (not milliseconds)', () => {
+ const timeout: number = entry.hooks[0].timeout;
+ expect(typeof timeout).toBe('number');
+ expect(timeout).toBeGreaterThan(0);
+ expect(timeout).toBeLessThan(120);
+ });
+});
+
+// ─── Source regressions ─────────────────────────────────────────────
+
+describe('Factory hook source regressions', () => {
+ const source = fs.readFileSync(HOOK, 'utf-8');
+
+ it('wires the augment fan-out guard (acquireHookSlot + finally release)', () => {
+ expect(source).toContain("require('./hook-lock.js')");
+ expect(source).toContain('acquireHookSlot(');
+ expect(source).toMatch(/finally\s*\{[^}]*release\(\)/s);
+ });
+
+ it('wires the LadybugDB owner probe before running augment', () => {
+ expect(source).toContain("require('./hook-db-lock-probe.cjs')");
+ expect(source).toContain('hasGitNexusDbLockedByGitNexusServer(');
+ });
+
+ it('never passes shell: true / shell: isWin to spawnSync (injection risk)', () => {
+ for (const line of source.split('\n')) {
+ const t = line.trim();
+ if (t.startsWith('//') || t.startsWith('*')) continue;
+ expect(/shell:\s*(true|isWin)/.test(line), `injection risk: ${t}`).toBe(false);
+ }
+ });
+
+ it('invokes npx.cmd directly on Windows instead of a shell', () => {
+ expect(source).toContain('npx.cmd');
+ });
+
+ // The npx fallback runs on ordinary tool calls whenever the CLI is not on
+ // PATH, so an unpinned ref would execute whatever currently owns the tag.
+ it('pins the npx fallback to the manifest version, never a mutable tag', () => {
+ expect(source).not.toContain('gitnexus@latest');
+ expect(source).toContain("require('../.factory-plugin/plugin.json')");
+ expect(source).toContain('`gitnexus@${PINNED_VERSION}`');
+ });
+
+ // Windows regression: Node refuses to spawn the .cmd launcher shims without a
+ // shell (CVE-2024-27980), so `node ` is the only branch that runs
+ // there. Same escape hatch the Claude adapter honors.
+ it('prefers GITNEXUS_HOOK_CLI_PATH via process.execPath', () => {
+ expect(source).toContain('GITNEXUS_HOOK_CLI_PATH');
+ expect(source).toMatch(/spawnSync\(\s*process\.execPath/);
+ });
+
+ it('passes the pattern after the -- end-of-options marker', () => {
+ expect(source).toMatch(/'augment',\s*'--',\s*pattern/);
+ });
+
+ it('validates cwd is absolute and gates on a non-registry .gitnexus dir', () => {
+ expect(source).toMatch(/path\.isAbsolute\(cwd\)/);
+ expect(source).toContain('isGlobalRegistryDir');
+ });
+
+ it('emits Factory-shape hookSpecificOutput.additionalContext', () => {
+ expect(source).toContain('hookSpecificOutput');
+ expect(source).toContain('additionalContext');
+ });
+
+ it('rejects patterns shorter than 3 chars', () => {
+ expect(source).toMatch(/length\s*<\s*3/);
+ });
+});
+
+// ─── Execute pattern parsing (shares the Cursor adapter's #2938 matrix) ─
+
+describe('Factory Execute pattern parser', () => {
+ it.each([
+ ['rg "User Service" src/', 'User Service'],
+ ["grep 'error boundary' -- src/", 'error boundary'],
+ ['rg User\\ Service src/', 'User Service'],
+ [String.raw`rg "C:\Users" src/`, String.raw`C:\Users`],
+ ['rg -e "User Service" src/', 'User Service'],
+ ['rg --regexp=UserService src/', 'UserService'],
+ ['grep -eUserService src/', 'UserService'],
+ ['/usr/bin/rg -- "User Service" src/', 'User Service'],
+ ['rg -- -error src/', '-error'],
+ ['rg "validateUser"', 'validateUser'],
+ ['rg -t ts UserService src/', 'UserService'],
+ ['rg --glob "*.ts" UserService', 'UserService'],
+ ['rg ab src/', null],
+ ])('extracts %j from %j', (command, expected) => {
+ expect(parseRgGrepPattern(command)).toBe(expected);
+ });
+});
+
+// ─── Behavior: early-exit paths (no augment spawned) ────────────────
+
+describe('Factory hook behavior — early exits', () => {
+ let tmpDir: string;
+
+ beforeAll(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-factory-early-'));
+ });
+ afterAll(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
+
+ it('exits cleanly on empty stdin', () => {
+ const r = spawnSync(process.execPath, [HOOK], {
+ input: '',
+ encoding: 'utf-8',
+ timeout: 10000,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ expect(r.status).toBe(0);
+ expect(r.stdout.trim()).toBe('');
+ });
+
+ it('exits cleanly on invalid JSON stdin', () => {
+ const r = spawnSync(process.execPath, [HOOK], {
+ input: 'not json',
+ encoding: 'utf-8',
+ timeout: 10000,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ expect(r.status).toBe(0);
+ expect(r.stdout.trim()).toBe('');
+ });
+
+ it('ignores non-PostToolUse events', () => {
+ const r = runHook(HOOK, {
+ hook_event_name: 'PreToolUse',
+ tool_name: 'Grep',
+ tool_input: { pattern: 'validateUser' },
+ cwd: tmpDir,
+ });
+ expect(r.status).toBe(0);
+ expect(r.stdout.trim()).toBe('');
+ });
+
+ it('produces no output when cwd is relative', () => {
+ const r = runHook(HOOK, {
+ hook_event_name: 'PostToolUse',
+ tool_name: 'Grep',
+ tool_input: { pattern: 'validateUser' },
+ cwd: 'relative/path',
+ });
+ expect(r.status).toBe(0);
+ expect(r.stdout.trim()).toBe('');
+ });
+
+ it('produces no output when cwd has no .gitnexus index', () => {
+ const r = runHook(HOOK, {
+ hook_event_name: 'PostToolUse',
+ tool_name: 'Grep',
+ tool_input: { pattern: 'validateUser' },
+ cwd: tmpDir,
+ });
+ expect(r.status).toBe(0);
+ expect(r.stdout.trim()).toBe('');
+ });
+
+ it('produces no output for unmatched tools or Execute without rg/grep', () => {
+ for (const input of [
+ { tool_name: 'MadeUpTool', tool_input: { foo: 'bar' } },
+ { tool_name: 'Execute', tool_input: { command: 'ls -la' } },
+ { tool_name: 'Grep', tool_input: { pattern: 'is' } }, // < 3 chars
+ ]) {
+ const r = runHook(HOOK, { hook_event_name: 'PostToolUse', cwd: tmpDir, ...input });
+ expect(r.status).toBe(0);
+ expect(r.stdout.trim()).toBe('');
+ }
+ });
+});
+
+// ─── Behavior: happy path (augment via a fake gitnexus on PATH) ─────
+
+describe('Factory hook behavior — augment', () => {
+ let repoDir: string;
+ let binDir: string;
+
+ beforeAll(() => {
+ repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-factory-repo-'));
+ // Bare .gitnexus/ (no registry.json/repos) → treated as a repo index; no
+ // `lbug` file → the DB-owner probe short-circuits false and augment runs.
+ fs.mkdirSync(path.join(repoDir, '.gitnexus'), { recursive: true });
+ binDir = createHookToolDir({ gitnexusStderr: '[GitNexus] graph context for validateUser' });
+ });
+ afterAll(() => {
+ fs.rmSync(repoDir, { recursive: true, force: true });
+ fs.rmSync(binDir, { recursive: true, force: true });
+ });
+
+ it('emits augment stderr as additionalContext for a Grep search', () => {
+ const r = runHook(
+ HOOK,
+ {
+ hook_event_name: 'PostToolUse',
+ tool_name: 'Grep',
+ tool_input: { pattern: 'validateUser' },
+ cwd: repoDir,
+ },
+ undefined,
+ { env: hookEnv(binDir) },
+ );
+ expect(r.status).toBe(0);
+ const out = parseHookOutput(r.stdout);
+ expect(out?.hookEventName).toBe('PostToolUse');
+ expect(out?.additionalContext).toContain('graph context for validateUser');
+ });
+});
+
+// ─── Behavior: fan-out guard skips when all slots are held ──────────
+
+describe('Factory hook behavior — augment fan-out guard', () => {
+ it('exits silently when all MAX_INFLIGHT slots hold live pids', async () => {
+ const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-factory-slots-'));
+ const lockDir = path.join(repoDir, '.gitnexus', '.hook-locks');
+ fs.mkdirSync(lockDir, { recursive: true });
+ const binDir = createHookToolDir({ gitnexusStderr: 'should never be emitted' });
+
+ const { spawn } = await import('child_process');
+ const sleepers = [0, 1, 2].map(() =>
+ spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], { stdio: 'ignore' }),
+ );
+ try {
+ sleepers.forEach((s, i) =>
+ fs.writeFileSync(path.join(lockDir, `slot-${i}.lock`), String(s.pid)),
+ );
+
+ const r = runHook(
+ HOOK,
+ {
+ hook_event_name: 'PostToolUse',
+ tool_name: 'Grep',
+ tool_input: { pattern: 'validateUser' },
+ cwd: repoDir,
+ },
+ undefined,
+ { env: hookEnv(binDir) },
+ );
+
+ expect(r.status).toBe(0);
+ expect(r.stdout.trim()).toBe('');
+ } finally {
+ for (const s of sleepers) {
+ try {
+ s.kill();
+ } catch {
+ /* ignore */
+ }
+ }
+ fs.rmSync(repoDir, { recursive: true, force: true });
+ fs.rmSync(binDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/gitnexus/test/unit/setup-selection.test.ts b/gitnexus/test/unit/setup-selection.test.ts
index 7978ec2dc..a57689550 100644
--- a/gitnexus/test/unit/setup-selection.test.ts
+++ b/gitnexus/test/unit/setup-selection.test.ts
@@ -101,7 +101,7 @@ describe('setupCommand coding-agent selection', () => {
expect(process.exitCode).toBe(1);
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining(
- 'Valid values: cursor, claude, antigravity, opencode, codebuddy, qoder, codex',
+ 'Valid values: cursor, claude, antigravity, opencode, codebuddy, qoder, codex, droid',
),
);
await expect(
diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts
index 97744511d..e121e4ee1 100644
--- a/gitnexus/test/unit/setup.test.ts
+++ b/gitnexus/test/unit/setup.test.ts
@@ -712,6 +712,79 @@ describe('setupQoder', () => {
});
});
+describe('setupDroid (Factory)', () => {
+ let tempHome: string;
+ let originalHome: string | undefined;
+ let originalUserProfile: string | undefined;
+
+ const configPath = () => path.join(tempHome, '.factory', 'mcp.json');
+
+ beforeEach(async () => {
+ vi.resetModules();
+ vi.clearAllMocks();
+
+ originalHome = process.env.HOME;
+ originalUserProfile = process.env.USERPROFILE;
+ tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-droid-setup-'));
+ process.env.HOME = tempHome;
+ process.env.USERPROFILE = tempHome;
+
+ // Only create ~/.factory so other editors skip and don't pollute assertions.
+ await fs.mkdir(path.join(tempHome, '.factory'), { recursive: true });
+
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ });
+
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ process.env.HOME = originalHome;
+ process.env.USERPROFILE = originalUserProfile;
+ await fs.rm(tempHome, { recursive: true, force: true });
+ });
+
+ it('writes the MCP entry to ~/.factory/mcp.json under mcpServers', async () => {
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
+ expect(config.mcpServers.gitnexus).toBeDefined();
+ });
+
+ it('preserves existing servers in ~/.factory/mcp.json', async () => {
+ await fs.writeFile(
+ configPath(),
+ JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
+ 'utf-8',
+ );
+
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ const config = JSON.parse(await fs.readFile(configPath(), 'utf-8'));
+ expect(config.mcpServers.other).toEqual({ command: 'foo' });
+ expect(config.mcpServers.gitnexus).toBeDefined();
+ });
+
+ it('skips when ~/.factory directory does not exist', async () => {
+ await fs.rm(path.join(tempHome, '.factory'), { recursive: true, force: true });
+
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ await expect(fs.access(configPath())).rejects.toThrow();
+ });
+
+ it('leaves a corrupt ~/.factory/mcp.json untouched', async () => {
+ const corrupt = '{ this is not valid json !!!';
+ await fs.writeFile(configPath(), corrupt, 'utf-8');
+
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ expect(await fs.readFile(configPath(), 'utf-8')).toBe(corrupt);
+ });
+});
+
describe('Codex hooks (installClaudeSchemaHooks)', () => {
let tempHome: string;
let originalHome: string | undefined;
diff --git a/gitnexus/test/unit/sync-plugin-manifests.test.ts b/gitnexus/test/unit/sync-plugin-manifests.test.ts
index e932bad4a..39296f29c 100644
--- a/gitnexus/test/unit/sync-plugin-manifests.test.ts
+++ b/gitnexus/test/unit/sync-plugin-manifests.test.ts
@@ -9,8 +9,12 @@ const SURFACES = [
'.claude-plugin/marketplace.json',
'gitnexus-claude-plugin/.codex-plugin/plugin.json',
'.agents/plugins/marketplace.json',
+ 'gitnexus-factory-plugin/.factory-plugin/plugin.json',
+ '.factory-plugin/marketplace.json',
] as const;
+const FACTORY_MCP = 'gitnexus-factory-plugin/mcp.json';
+
const MCP_SKILL_DIRS = [
'gitnexus-plan',
'gitnexus-work',
@@ -24,7 +28,7 @@ const MCP_SKILL_DIRS = [
'gitnexus-refactoring',
] as const;
-const TOTAL_SURFACES = SURFACES.length + MCP_SKILL_DIRS.length;
+const TOTAL_SURFACES = SURFACES.length + MCP_SKILL_DIRS.length + 1;
function mcpPath(dir: string): string {
return `gitnexus-claude-plugin/skills/${dir}/mcp.json`;
@@ -56,8 +60,13 @@ function makeRoot(packageVersion: string, manifestVersion: string): string {
name: 'gitnexus-marketplace',
plugins: [{ name: 'gitnexus', version: manifestVersion, category: 'Developer Tools' }],
});
- for (const dir of MCP_SKILL_DIRS) {
- writeJson(root, mcpPath(dir), {
+ writeJson(root, SURFACES[4], { name: 'gitnexus', version: manifestVersion });
+ writeJson(root, SURFACES[5], {
+ name: 'gitnexus-marketplace',
+ plugins: [{ name: 'gitnexus', version: manifestVersion, source: './gitnexus-factory-plugin' }],
+ });
+ for (const dir of [...MCP_SKILL_DIRS.map(mcpPath), FACTORY_MCP]) {
+ writeJson(root, dir, {
mcpServers: {
gitnexus: { command: 'npx', args: ['-y', `gitnexus@${manifestVersion}`, 'mcp'] },
},
@@ -89,19 +98,19 @@ describe('syncPluginManifests (#2445)', () => {
expect(result.version).toBe('1.6.10-rc.29');
expect(result.synced).toHaveLength(TOTAL_SURFACES);
expect(result.stale.map(({ from }) => from)).toEqual(Array(TOTAL_SURFACES).fill('1.6.9'));
- expect(readVersions(root)).toEqual(Array(4).fill('1.6.10-rc.29'));
+ expect(readVersions(root)).toEqual(Array(SURFACES.length).fill('1.6.10-rc.29'));
});
- it('pins the gitnexus@ launch arg in every plugin skill mcp.json', () => {
+ it('pins the gitnexus@ launch arg in every executable mcp.json', () => {
const root = makeRoot('1.6.10-rc.29', '1.6.9');
syncPluginManifests(root);
- for (const dir of MCP_SKILL_DIRS) {
- const mcp = JSON.parse(readFileSync(path.join(root, mcpPath(dir)), 'utf8')) as {
+ for (const file of [...MCP_SKILL_DIRS.map(mcpPath), FACTORY_MCP]) {
+ const mcp = JSON.parse(readFileSync(path.join(root, file), 'utf8')) as {
mcpServers: { gitnexus: { args: string[] } };
};
- expect(mcp.mcpServers.gitnexus.args).toEqual(['-y', 'gitnexus@1.6.10-rc.29', 'mcp']);
+ expect(mcp.mcpServers.gitnexus.args, file).toEqual(['-y', 'gitnexus@1.6.10-rc.29', 'mcp']);
}
});
@@ -131,7 +140,7 @@ describe('syncPluginManifests (#2445)', () => {
expect(result.stale).toHaveLength(TOTAL_SURFACES);
expect(result.synced).toHaveLength(0);
- expect(readVersions(root)).toEqual(Array(4).fill('1.6.9'));
+ expect(readVersions(root)).toEqual(Array(SURFACES.length).fill('1.6.9'));
});
it('changes only the version text and preserves the surrounding formatting', () => {