gitnexus 1.6.13-rc.34 → 1.6.13-rc.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +30 -2
  2. package/dist/cli/editor-targets.d.ts +1 -1
  3. package/dist/cli/editor-targets.js +12 -0
  4. package/dist/cli/i18n/en.d.ts +1 -1
  5. package/dist/cli/i18n/en.js +1 -1
  6. package/dist/cli/i18n/resources.d.ts +1 -1
  7. package/dist/cli/i18n/zh-CN.js +1 -1
  8. package/dist/cli/index.js +1 -1
  9. package/dist/cli/setup.js +57 -0
  10. package/hooks/claude/hook-db-lock-probe.cjs +74 -43
  11. package/hooks/claude/hook-lock.cjs +130 -6
  12. package/hooks/claude/registry-query.cjs +18 -23
  13. package/package.json +1 -1
  14. package/scripts/cross-platform-tests.ts +1 -0
  15. package/scripts/sync-plugin-manifests.mjs +13 -0
  16. package/web/assets/{abnfDiagram-PAR5IXJI-nRjsvWp-.js → abnfDiagram-PAR5IXJI-CM9OPETj.js} +1 -1
  17. package/web/assets/{agent-Bv5z78CW.js → agent-C8bWDcKv.js} +2 -2
  18. package/web/assets/{architectureDiagram-2PNWDENW-56fl8SSH.js → architectureDiagram-2PNWDENW-CFGPVOFL.js} +1 -1
  19. package/web/assets/{chunk-32MCUW2P-xLjc0mcF.js → chunk-32MCUW2P-BAKWSoJ_.js} +1 -1
  20. package/web/assets/{chunk-CLS4B6BI-DPDLP81t.js → chunk-CLS4B6BI-C6Eyf-Zq.js} +1 -1
  21. package/web/assets/{chunk-NF366YI6-D2OzsNXY.js → chunk-NF366YI6-BjQUpZZg.js} +1 -1
  22. package/web/assets/{chunk-SLDQ463B-DUcQswS-.js → chunk-SLDQ463B-DAs7sJcm.js} +1 -1
  23. package/web/assets/{classDiagram-FGAMPOII-q43g3Dpn.js → classDiagram-FGAMPOII-ipJcwT2g.js} +1 -1
  24. package/web/assets/{classDiagram-v2-AI5RJEK4-q43g3Dpn.js → classDiagram-v2-AI5RJEK4-ipJcwT2g.js} +1 -1
  25. package/web/assets/{cynefinDiagram-2OOWUY4M-Cfoko1Ws.js → cynefinDiagram-2OOWUY4M-BfCkzyqC.js} +1 -1
  26. package/web/assets/{dagre-MPVFI544-CTob3D2r.js → dagre-MPVFI544-CMeiFj2H.js} +1 -1
  27. package/web/assets/{diagram-AE4NWXFW-Dt2xfADa.js → diagram-AE4NWXFW-B7JUvrjH.js} +1 -1
  28. package/web/assets/{diagram-BUJID5UG-D-RUCl07.js → diagram-BUJID5UG-KMWjNniB.js} +1 -1
  29. package/web/assets/{diagram-C44CZ5NI-B9gPH4Gl.js → diagram-C44CZ5NI-Bx93uHLb.js} +1 -1
  30. package/web/assets/{diagram-EYOLEXXS-wR__cI23.js → diagram-EYOLEXXS-Dsja58oR.js} +1 -1
  31. package/web/assets/{ebnfDiagram-FTPZ3PV6-uw79FGz3.js → ebnfDiagram-FTPZ3PV6-Iwxb9NAS.js} +1 -1
  32. package/web/assets/{erDiagram-H3ERR26V-ZP_VIa5d.js → erDiagram-H3ERR26V-CJU6qpcb.js} +1 -1
  33. package/web/assets/{flowDiagram-YHGXBVSY-DEsmG3vH.js → flowDiagram-YHGXBVSY-Dz6dhw6j.js} +1 -1
  34. package/web/assets/{index-DofRiYRj.js → index-dXsywuPQ.js} +7 -7
  35. package/web/assets/{infoDiagram-LV547AFH-D1JPzJlf.js → infoDiagram-LV547AFH-P074dR6u.js} +1 -1
  36. package/web/assets/{ishikawaDiagram-PUJQGNDS-DxWZMRPX.js → ishikawaDiagram-PUJQGNDS-CZlaIkzm.js} +1 -1
  37. package/web/assets/{kanban-definition-JSQERAZB-S1lKJN_M.js → kanban-definition-JSQERAZB-BLX38hbF.js} +1 -1
  38. package/web/assets/{mindmap-definition-VBJCLQLM-JwX0Gc3J.js → mindmap-definition-VBJCLQLM-Bnu-7Z_9.js} +1 -1
  39. package/web/assets/{node.browser-C9G7Ehhe.js → node.browser-DIc1knUO.js} +1 -1
  40. package/web/assets/{pegDiagram-7WILOQTJ-DJWiAJJM.js → pegDiagram-7WILOQTJ-SH-3F770.js} +1 -1
  41. package/web/assets/{pieDiagram-EFA7CBSB-3i7IEboS.js → pieDiagram-EFA7CBSB-BGEXuMLa.js} +1 -1
  42. package/web/assets/{railroadDiagram-5NIQVNLQ-CQGcjlaN.js → railroadDiagram-5NIQVNLQ-DZMK703w.js} +1 -1
  43. package/web/assets/{requirementDiagram-SXREGOKZ-DH-LMk5X.js → requirementDiagram-SXREGOKZ-DLW6bNTm.js} +1 -1
  44. package/web/assets/{sequenceDiagram-52ZFIFDL-CrUW-Kdw.js → sequenceDiagram-52ZFIFDL-Bu6IKvYQ.js} +1 -1
  45. package/web/assets/{stateDiagram-3QAKPIVI-BuvX8wUJ.js → stateDiagram-3QAKPIVI-BrLxhUMB.js} +1 -1
  46. package/web/assets/{stateDiagram-v2-HN2P4RDZ-N7DodpOh.js → stateDiagram-v2-HN2P4RDZ-yZRLNv94.js} +1 -1
  47. package/web/assets/{swimlanes-QLVP42QD-DpvGI6UP.js → swimlanes-QLVP42QD-BN3CFF8X.js} +1 -1
  48. package/web/assets/{swimlanesDiagram-JKPDBFCN-Rd-o-as4.js → swimlanesDiagram-JKPDBFCN-BQ7NwLky.js} +1 -1
  49. package/web/assets/{timeline-definition-GS45XAXW-B9Rw1d9t.js → timeline-definition-GS45XAXW-DMlAtIe0.js} +1 -1
  50. package/web/assets/{vennDiagram-QIQU2ZRJ-CGo08RCI.js → vennDiagram-QIQU2ZRJ-DWmjtOEV.js} +1 -1
  51. package/web/assets/{wardleyDiagram-U6YNM66N-Dk7T5hBn.js → wardleyDiagram-U6YNM66N-BRdmEKKB.js} +1 -1
  52. package/web/assets/{xychartDiagram-XJAZEDNM-BJr5UGSi.js → xychartDiagram-XJAZEDNM-roHne1SF.js} +1 -1
  53. package/web/index.html +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
4
4
 
5
- Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
5
+ Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Factory** (Droid), **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus)
8
8
  [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](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
44
44
  | **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
45
45
  | **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
46
46
  | **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** |
47
+ | **Factory** (Droid) | Yes | Yes | Yes (PostToolUse, [plugin](../gitnexus-factory-plugin/)) | **Full** |
47
48
  | **OpenCode** | Yes | Yes | — | MCP + Skills |
48
49
  | **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
49
50
  | **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
50
51
  | **Windsurf** | Yes | — | — | MCP |
51
52
 
52
- > **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.
53
+ > **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.
53
54
 
54
55
  ### Community Integrations
55
56
 
@@ -88,6 +89,33 @@ codex plugin marketplace add abhigyanpatwari/GitNexus
88
89
 
89
90
  > **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.
90
91
 
92
+ ### Factory (Droid) (full support — MCP + skills + hooks)
93
+
94
+ `gitnexus setup -c droid` writes the MCP server to `~/.factory/mcp.json` and installs skills to
95
+ `~/.factory/skills/`. To configure MCP by hand instead, add to `~/.factory/mcp.json`
96
+ ([user scope](https://docs.factory.ai/cli/configuration/mcp) — applies to all projects):
97
+
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "gitnexus": {
102
+ "command": "npx",
103
+ "args": ["-y", "gitnexus@latest", "mcp"]
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ For the PostToolUse search-augment hook, install the bundled
110
+ [`gitnexus-factory-plugin/`](../gitnexus-factory-plugin/) with `droid plugin install gitnexus@<marketplace>`
111
+ from a marketplace that includes this repo, or point Droid at it via `extraKnownMarketplaces` in
112
+ `.factory/settings.json`.
113
+
114
+ > **Factory notes:** Droid reads [AGENTS.md natively](https://docs.factory.ai/), which already carries the
115
+ > GitNexus context block, so no SessionStart hook is registered. Pick **one** install route
116
+ > (`gitnexus setup -c droid` **or** the plugin) — the plugin ships its own MCP entry, so installing both
117
+ > can register the server twice.
118
+
91
119
  ### Cursor / Windsurf
92
120
 
93
121
  Add to `~/.cursor/mcp.json` (global — works for all projects):
@@ -15,7 +15,7 @@
15
15
  * implementations remain behaviourally symmetrical on top of this shared
16
16
  * structure.
17
17
  */
18
- export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codebuddy' | 'qoder' | 'codex';
18
+ export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codebuddy' | 'qoder' | 'codex' | 'droid';
19
19
  /** An editor whose MCP config is a JSONC document (server keyed by name). */
20
20
  export interface McpJsoncTarget {
21
21
  id: EditorId;
@@ -76,6 +76,15 @@ export function getEditorTargets(home = os.homedir()) {
76
76
  file: path.join(home, '.qoder.json'),
77
77
  keyPath: ['mcpServers', 'gitnexus'],
78
78
  },
79
+ {
80
+ id: 'droid',
81
+ label: 'Factory Droid',
82
+ // Factory's user-scope MCP config (https://docs.factory.ai/cli/configuration/mcp).
83
+ // Same `mcpServers` object shape as Cursor/Claude; user config takes
84
+ // precedence over project-level .factory/mcp.json.
85
+ file: path.join(home, '.factory', 'mcp.json'),
86
+ keyPath: ['mcpServers', 'gitnexus'],
87
+ },
79
88
  ];
80
89
  const codex = {
81
90
  id: 'codex',
@@ -98,6 +107,9 @@ export function getEditorTargets(home = os.homedir()) {
98
107
  { id: 'qoder', label: 'Qoder', dir: path.join(home, '.qoder', 'skills') },
99
108
  // Codex reads skills from ~/.agents/skills (not ~/.codex).
100
109
  { id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
110
+ // Factory Droid reads user-scope skills from ~/.factory/skills/{name}/SKILL.md
111
+ // (https://docs.factory.ai/cli/configuration/skills).
112
+ { id: 'droid', label: 'Factory Droid', dir: path.join(home, '.factory', 'skills') },
101
113
  ];
102
114
  const hooks = [
103
115
  {
@@ -152,7 +152,7 @@ export declare const en: {
152
152
  readonly 'help.command.help.description': 'display help for command';
153
153
  readonly 'help.option.help': 'display help for command';
154
154
  readonly 'help.option.version': 'output the version number';
155
- readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex';
155
+ readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid';
156
156
  readonly 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors';
157
157
  readonly 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml';
158
158
  readonly 'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.';
@@ -154,7 +154,7 @@ export const en = {
154
154
  'help.command.help.description': 'display help for command',
155
155
  'help.option.help': 'display help for command',
156
156
  'help.option.version': 'output the version number',
157
- 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
157
+ 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid',
158
158
  'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
159
159
  'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
160
160
  'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.',
@@ -153,7 +153,7 @@ export declare const cliResources: {
153
153
  readonly 'help.command.help.description': 'display help for command';
154
154
  readonly 'help.option.help': 'display help for command';
155
155
  readonly 'help.option.version': 'output the version number';
156
- readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex';
156
+ readonly 'help.command.setup.description': 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid';
157
157
  readonly 'help.command.uninstall.description': 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors';
158
158
  readonly 'help.command.autoSync.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml';
159
159
  readonly 'help.autoSync.details': '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.';
@@ -152,7 +152,7 @@ export const zhCN = {
152
152
  'help.command.help.description': '显示命令帮助',
153
153
  'help.option.help': '显示命令帮助',
154
154
  'help.option.version': '输出版本号',
155
- 'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
155
+ 'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex、Factory Droid 配置 MCP',
156
156
  'help.command.uninstall.description': '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
157
157
  'help.command.autoSync.description': '控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
158
158
  'help.autoSync.details': '\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:仅允许 github.com、gitlab.com 和 gitee.com 上的 SSH 地址。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。',
package/dist/cli/index.js CHANGED
@@ -17,7 +17,7 @@ function collectCodingAgents(value, previous) {
17
17
  program.name('gitnexus').description('GitNexus local CLI and MCP server').version(packageVersion());
18
18
  program
19
19
  .command('setup')
20
- .description('One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex')
20
+ .description('One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid')
21
21
  .option('-c, --coding-agent <agents>', 'Configure only these coding agents (comma-separated or repeatable)', collectCodingAgents)
22
22
  .action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
23
23
  program
package/dist/cli/setup.js CHANGED
@@ -70,6 +70,7 @@ const CODING_AGENT_IDS = {
70
70
  codebuddy: 'codebuddy',
71
71
  qoder: 'qoder',
72
72
  codex: 'codex',
73
+ droid: 'droid',
73
74
  };
74
75
  const SUPPORTED_CODING_AGENTS = Object.values(CODING_AGENT_IDS);
75
76
  function selectedCodingAgents(values) {
@@ -824,6 +825,55 @@ async function installQoderSkills(result) {
824
825
  result.errors.push(`Qoder skills: ${err instanceof Error ? err.message : String(err)}`);
825
826
  }
826
827
  }
828
+ /**
829
+ * Configure the GitNexus MCP server for Factory Droid.
830
+ *
831
+ * Factory stores user-scope MCP config in ~/.factory/mcp.json using the same
832
+ * `{ mcpServers: { <name>: {...} } }` JSONC shape as Cursor/Claude
833
+ * (https://docs.factory.ai/cli/configuration/mcp), so we reuse mergeJsoncFile
834
+ * rather than shelling out to `droid mcp add` — that keeps setup working even
835
+ * when the `droid` binary isn't on PATH.
836
+ */
837
+ async function setupDroid(result) {
838
+ const factoryDir = path.join(os.homedir(), '.factory');
839
+ if (!(await dirExists(factoryDir))) {
840
+ result.skipped.push('Factory Droid (not installed)');
841
+ return;
842
+ }
843
+ const { file: mcpPath, keyPath } = mcpTarget('droid');
844
+ try {
845
+ const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
846
+ if (ok) {
847
+ result.configured.push('Factory Droid');
848
+ }
849
+ else {
850
+ result.errors.push('Factory Droid: mcp.json is corrupt — skipping to preserve existing content');
851
+ }
852
+ }
853
+ catch (err) {
854
+ result.errors.push(`Factory Droid: ${err.message}`);
855
+ }
856
+ }
857
+ /**
858
+ * Install global Factory Droid skills to ~/.factory/skills/{name}/SKILL.md
859
+ * (https://docs.factory.ai/cli/configuration/skills — same SKILL.md layout as
860
+ * Claude Code).
861
+ */
862
+ async function installDroidSkills(result) {
863
+ const factoryDir = path.join(os.homedir(), '.factory');
864
+ if (!(await dirExists(factoryDir)))
865
+ return;
866
+ const skillsDir = skillTarget('droid').dir;
867
+ try {
868
+ const installed = await installSkillsTo(skillsDir);
869
+ if (installed.length > 0) {
870
+ result.configured.push(`Factory Droid skills (${installed.length} skills → ~/.factory/skills/)`);
871
+ }
872
+ }
873
+ catch (err) {
874
+ result.errors.push(`Factory Droid skills: ${err.message}`);
875
+ }
876
+ }
827
877
  /**
828
878
  * Build a TOML section for Codex MCP config (~/.codex/config.toml).
829
879
  */
@@ -1107,6 +1157,8 @@ export const setupCommand = async (options) => {
1107
1157
  await setupQoder(result);
1108
1158
  if (selected.has('codex'))
1109
1159
  await setupCodex(result);
1160
+ if (selected.has('droid'))
1161
+ await setupDroid(result);
1110
1162
  // Install global skills for platforms that support them
1111
1163
  if (selected.has('claude')) {
1112
1164
  await installClaudeCodeSkills(result);
@@ -1128,6 +1180,11 @@ export const setupCommand = async (options) => {
1128
1180
  await installCodexSkills(result);
1129
1181
  await installClaudeSchemaHooks(result, 'codex');
1130
1182
  }
1183
+ // MCP + skills only. Factory hooks (Execute matcher, PostToolUse) ship in the
1184
+ // standalone gitnexus-factory-plugin instead of the setup path — add a droid
1185
+ // hook target + adapter here if setup-installed hooks are wanted.
1186
+ if (selected.has('droid'))
1187
+ await installDroidSkills(result);
1131
1188
  // Print results
1132
1189
  if (result.configured.length > 0) {
1133
1190
  console.log(' Configured:');
@@ -80,9 +80,11 @@ function debugLog(msg) {
80
80
 
81
81
  function resolveHookBinary(tool) {
82
82
  const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
83
- const fromEnv = process.env[envKey];
84
- if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
85
- return String(fromEnv);
83
+ // Trim once, exactly as hasMissingHookBinaryOverride does, so a padded but
84
+ // valid override (" /tmp/lsof ") is both accepted there and used here.
85
+ const fromEnv = process.env[envKey] ? String(process.env[envKey]).trim() : '';
86
+ if (fromEnv && fs.existsSync(fromEnv)) {
87
+ return fromEnv;
86
88
  }
87
89
  const candidates =
88
90
  tool === 'lsof'
@@ -177,7 +179,13 @@ function resolveUnixGuardTimeout() {
177
179
  const trimmed = fromEnv ? String(fromEnv).trim() : '';
178
180
  if (trimmed === 'disabled') return unixGuardTimeoutCache;
179
181
  const candidates = [];
180
- if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
182
+ // Resolve the override against THIS process's cwd — the directory the
183
+ // existsSync check and the self-test run in — so the cached/returned path is
184
+ // always absolute. The adapters spawn the wrapper with a different `cwd`
185
+ // (the tool request's), where a relative value would resolve elsewhere
186
+ // (ENOENT), and a slashless name would switch to a PATH lookup.
187
+ const override = trimmed ? path.resolve(trimmed) : '';
188
+ if (override && fs.existsSync(override)) candidates.push(override);
181
189
  for (const builtin of [
182
190
  '/usr/bin/timeout',
183
191
  '/bin/timeout',
@@ -317,15 +325,30 @@ function getProcRoot() {
317
325
  // `node <abs path to .../node_modules/gitnexus/dist/cli/index.js> mcp` line
318
326
  // (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large
319
327
  // enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for
320
- // tests; never goes below PROC_CMDLINE_FLOOR.
328
+ // tests via GITNEXUS_HOOK_PROC_CMDLINE_MAX: an integer in
329
+ // [PROC_CMDLINE_FLOOR, PROC_CMDLINE_CEIL] is used as-is; a larger integer is
330
+ // CLAMPED to PROC_CMDLINE_CEIL; anything else (below the floor, fractional,
331
+ // non-numeric, Infinity) falls back to the 16 KiB default.
321
332
  const PROC_CMDLINE_FLOOR = 4096;
333
+ // Upper bound for a single cmdline read chunk, and the absolute ceiling of the
334
+ // escalation path in readLinuxCmdline (same 256 KiB — no single read may exceed
335
+ // what the whole escalation is allowed to collect). Without it, an oversized
336
+ // override (e.g. 2**40 — past buffer.constants.MAX_LENGTH on older Node lines
337
+ // and unallocatable in practice on any) made Buffer.allocUnsafe throw; readLinuxCmdline's catch turned that into '' (a
338
+ // NON-candidate), so a real server owner was silently missed (fail-OPEN, the
339
+ // #1492 race). Oversized values are clamped rather than defaulted: the operator
340
+ // asked for MORE bytes, and the ceiling is the most the read will ever collect
341
+ // anyway, so clamping honours the intent while keeping allocation bounded.
342
+ const PROC_CMDLINE_CEIL = 262144;
322
343
  function getCmdlineMaxBytes() {
323
344
  const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX;
324
345
  // Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at
325
346
  // 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the
326
347
  // default; trailing garbage ("8abc") now -> NaN -> default (stricter).
327
348
  const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN;
328
- if (Number.isFinite(n) && n >= PROC_CMDLINE_FLOOR) return n;
349
+ // Number.isInteger rejects NaN, +/-Infinity and fractions (a fractional
350
+ // Buffer/readSync length is not a byte count).
351
+ if (Number.isInteger(n) && n >= PROC_CMDLINE_FLOOR) return Math.min(n, PROC_CMDLINE_CEIL);
329
352
  return 16384;
330
353
  }
331
354
 
@@ -381,19 +404,24 @@ const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout');
381
404
 
382
405
  // Bounded /proc/<pid>/cmdline read for Phase 1. openSync+readSync (not
383
406
  // readFileSync) so a D-state holder cannot stall the hook on a huge or
384
- // never-EOF argv: we read at most `cap` bytes and stop. cmdline separates argv
385
- // with NULs; convert to spaces for isGitNexusServerCommand.
407
+ // never-EOF argv: we read in `cap`-sized chunks and stop as soon as the text
408
+ // holds both server tokens, at EOF, at PROC_CMDLINE_CEIL, or when the scan
409
+ // budget runs out (see below). cmdline separates argv with NULs; convert to
410
+ // spaces for isGitNexusServerCommand.
386
411
  //
387
412
  // Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the
388
413
  // first path component while the `mcp`/`serve` mode token is the LAST argv, so
389
414
  // a naive 4 KB read could clip the mode token off a server launched with a very
390
415
  // long interpreter path and silently miss a real owner. We mitigate two ways:
391
- // (a) the default cap (16 KiB) already clears realistic lines; (b) if the first
392
- // read fills the cap AND already contains the `gitnexus` token but no mode
393
- // token yet, we keep reading in bounded chunks (up to a hard ceiling) until the
394
- // mode token appears or the file ends — so a genuine server is never missed for
395
- // want of a few more bytes, while non-candidates still pay only the initial
396
- // bounded read.
416
+ // (a) the default cap (16 KiB) already clears realistic lines, so almost every
417
+ // process is decided by the first read hitting EOF; (b) a read that fills the
418
+ // cap stops early ONLY once it holds BOTH tokens (decided owner). Holding one
419
+ // token, or neither, decides nothing: interpreter flags can put a mode-looking
420
+ // word first (`node --require mcp .../gitnexus/... serve`) or push the gitnexus
421
+ // path past the first chunk. So we keep reading in cap-sized chunks until both
422
+ // tokens appear, the file ends, or the hard ceiling is reached. Only processes
423
+ // that passed the Phase 0 comm prefilter AND have a cmdline longer than the cap
424
+ // ever escalate, and each escalation step is budget-gated (below).
397
425
  //
398
426
  // Budget (F3): the escalation loop above is the one place a SINGLE pathological
399
427
  // candidate could read up to HARD_CEIL (256 KiB) before the next scan-level
@@ -411,7 +439,7 @@ function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) {
411
439
  return '';
412
440
  }
413
441
  try {
414
- const HARD_CEIL = 262144; // 256 KiB absolute ceiling for the escalation path
442
+ const HARD_CEIL = PROC_CMDLINE_CEIL; // 256 KiB absolute ceiling for the escalation path
415
443
  let collected = Buffer.alloc(0);
416
444
  let offset = 0;
417
445
  let chunkCap = cap;
@@ -425,16 +453,12 @@ function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) {
425
453
  collected = Buffer.concat([collected, buf.subarray(0, bytes)]);
426
454
  offset += bytes;
427
455
  const text = collected.toString('utf8').replace(/\0+/g, ' ');
428
- // Stop early when we can already decide "owner": has both the gitnexus
429
- // token and a mode token. Keep going only when gitnexus is present but
430
- // the mode token might be just past the boundary.
431
- const hasGitNexus =
432
- /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(text) ||
433
- /node_modules[/\\]gitnexus[/\\]/.test(text);
434
- const hasMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(text);
435
- if (hasMode) break; // decided (positive); isGitNexusServerCommand re-checks below
456
+ // Stop early only when the partial read is DECIDED: both the gitnexus
457
+ // token and a mode token are present (isGitNexusServerCommand is exactly
458
+ // that conjunction). A partial read missing either token is undecided —
459
+ // the missing one may lie past the chunk boundary — so it keeps reading.
460
+ if (isGitNexusServerCommand(text)) break; // decided (positive)
436
461
  if (bytes < chunkCap) break; // EOF: full cmdline read, definitive
437
- if (!hasGitNexus) break; // not a candidate; do not escalate the read
438
462
  if (offset >= HARD_CEIL) break; // bounded escalation only
439
463
  // Budget gate the escalation: a single huge-argv candidate must not burn
440
464
  // the whole scan deadline before we re-check. Return the timeout sentinel
@@ -578,17 +602,24 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
578
602
  );
579
603
  return 'timeout';
580
604
  }
581
- // Any other shape (ENOTDIR — fd path is not a directory at all, so this
582
- // is not a plausible live-procfs owner — and the long tail) is treated as
583
- // "this candidate is not an owner": move to the next candidate instead of
584
- // the old blanket 'owned'. If no other candidate owns the lbug the scan
585
- // ends not-owned (dispatcher fail-open) — acceptable because ENOTDIR means
586
- // the fd entry is structurally not a real /proc/<pid>/fd.
605
+ if (code === 'ENOTDIR') {
606
+ // The fd path is not a directory at all, so this is structurally not a
607
+ // real /proc/<pid>/fd — not a plausible live owner. Move on.
608
+ debugLog(
609
+ `fd dir not a directory for candidate pid ${pidStr} (ENOTDIR); ` +
610
+ `treating candidate as non-owner -> continue`,
611
+ );
612
+ continue;
613
+ }
614
+ // Any other error (EMFILE, ENFILE, ENOMEM, EINTR, no code, …) says nothing
615
+ // about whether this already-identified server candidate holds the lbug.
616
+ // Only ENOENT/ENOTDIR above establish non-ownership; everything else is
617
+ // inconclusive and fails closed via 'timeout', same as EACCES/EIO.
587
618
  debugLog(
588
- `fd dir not a readable directory for candidate pid ${pidStr} ` +
589
- `(${code || 'unknown'}); treating candidate as non-owner -> continue`,
619
+ `fd dir read failed for candidate pid ${pidStr} ` +
620
+ `(${code || 'unknown'}); probe inconclusive -> fail-closed (timeout)`,
590
621
  );
591
- continue;
622
+ return 'timeout';
592
623
  }
593
624
  for (const fd of fds) {
594
625
  if (outOfBudget()) return 'timeout';
@@ -707,16 +738,12 @@ module.exports = {
707
738
  // name is pinned by a source-contract test.
708
739
  linuxProcScanFindGitNexusServer,
709
740
  // #2163 follow-up: the hook adapters wrap the augment CLI in the same
710
- // guard. Returns a self-tested wrapper path — the built-in candidates are
711
- // always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the
712
- // exact string that passed the self-test. Same string is also the same
713
- // RESOLUTION for absolute paths and for slashless names (PATH lookup is
714
- // cwd-independent); a slash-containing RELATIVE override, however, is
715
- // existsSync-checked and self-tested against this process's cwd while the
716
- // adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such
717
- // a value can pass here yet ENOENT at the augment call site — set the
718
- // override to an absolute path. Returns null when the wrapper is
719
- // disabled/unavailable. Never call on win32 (see its JSDoc).
741
+ // guard. Returns a self-tested, always-ABSOLUTE wrapper path: the built-in
742
+ // candidates are absolute, and a GITNEXUS_HOOK_TIMEOUT_PATH override is
743
+ // path.resolve()d against this process's cwd before its existsSync check
744
+ // and self-test, so the adapters can spawn it under any `cwd` option.
745
+ // Returns null when the wrapper is disabled/unavailable. Never call on
746
+ // win32 (see its JSDoc).
720
747
  resolveUnixGuardTimeout,
721
748
  // Exported for white-box unit tests of the numeric-env parsing (#2183 review):
722
749
  // Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace
@@ -725,4 +752,8 @@ module.exports = {
725
752
  // otherwise only observable indirectly through scan timing/escalation.
726
753
  getCmdlineMaxBytes,
727
754
  resolveLinuxProcBudgetMs,
755
+ // Exported for white-box tests pinning that the override check and the
756
+ // override lookup agree on whitespace-padded GITNEXUS_HOOK_{LSOF,PS}_PATH.
757
+ resolveHookBinary,
758
+ hasMissingHookBinaryOverride,
728
759
  };
@@ -1,3 +1,4 @@
1
+ const crypto = require('crypto');
1
2
  const fs = require('fs');
2
3
  const path = require('path');
3
4
 
@@ -5,6 +6,128 @@ const HOOK_LOCK_SUBDIR = '.hook-locks';
5
6
  const HOOK_LOCK_MAX_INFLIGHT = 3;
6
7
  const HOOK_LOCK_STALE_MS = 30000;
7
8
 
9
+ // An evictor's claim marker older than this belongs to a crashed evictor.
10
+ // The critical section it guards is a few syscalls (token read, lstat,
11
+ // unlink), so any live evictor finishes orders of magnitude sooner; kept well
12
+ // under HOOK_LOCK_STALE_MS so an orphan never blocks a slot for long.
13
+ const HOOK_LOCK_EVICT_MARKER_STALE_MS = 5000;
14
+
15
+ // Same file iff inode identity AND content metadata match. dev+ino alone is
16
+ // not enough: filesystems reuse a freed inode number immediately (ext4), so a
17
+ // file recreated after an unlink can carry the old file's ino. bigint stats
18
+ // keep Windows' 64-bit file ids exact.
19
+ function sameSlotFile(a, b) {
20
+ return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs;
21
+ }
22
+
23
+ function readMarkerToken(marker) {
24
+ try {
25
+ return fs.readFileSync(marker, 'utf-8');
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ // Stat and token of a marker, both taken from one open descriptor so they
32
+ // describe the same file (a path stat followed by a path read could straddle
33
+ // a replacement). O_NOFOLLOW where the platform has it: a marker is always a
34
+ // regular file this module created. Returns null when there is no marker.
35
+ function readMarkerSnapshot(marker) {
36
+ let fd;
37
+ try {
38
+ fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
39
+ return { stat: fs.fstatSync(fd, { bigint: true }), token: fs.readFileSync(fd, 'utf-8') };
40
+ } catch {
41
+ return null;
42
+ } finally {
43
+ if (fd !== undefined) {
44
+ try {
45
+ fs.closeSync(fd);
46
+ } catch {
47
+ /* already closed */
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ // Break an evictor's claim marker only if it is an orphan: older than
54
+ // HOOK_LOCK_EVICT_MARKER_STALE_MS, and still the exact file (identity and
55
+ // owner token) judged old when it is re-checked just before the unlink. A
56
+ // marker released and re-created by a new claimant in between is fresh, so
57
+ // it fails the check and stays.
58
+ function breakOrphanedMarker(marker) {
59
+ const seen = readMarkerSnapshot(marker);
60
+ if (!seen || Date.now() - Number(seen.stat.mtimeMs) <= HOOK_LOCK_EVICT_MARKER_STALE_MS) return;
61
+ const now = readMarkerSnapshot(marker);
62
+ if (!now || !sameSlotFile(now.stat, seen.stat) || now.token !== seen.token) return;
63
+ try {
64
+ fs.unlinkSync(marker);
65
+ } catch {
66
+ /* another contender already cleared it */
67
+ }
68
+ }
69
+
70
+ // Evict a slot judged stale from the `inspected` stat. A slot file is only
71
+ // ever deleted, never moved, and only by the evictor holding the per-slot
72
+ // `<slot>.evicting` marker, created O_EXCL with a token unique to this call.
73
+ // Every destructive step verifies first:
74
+ // - the slot is unlinked only if the marker still carries our token (an
75
+ // evictor stalled long enough for its marker to be broken as an orphan has
76
+ // lost its claim and backs off) and the slot is still the exact file
77
+ // inspected — identical dev/ino/size/mtimeNs means its content and age are
78
+ // unchanged, so the stale verdict still holds, while a slot recreated since
79
+ // inspection fails the check and its lock stands;
80
+ // - our marker is removed only if it still carries our token, so a marker
81
+ // that has passed to another claimant is left alone;
82
+ // - an orphaned marker is broken only if it is still the old file it was
83
+ // judged to be (see breakOrphanedMarker).
84
+ //
85
+ // Residual windows. POSIX has no conditional unlink, so each check-then-
86
+ // unlink pair keeps a gap of two adjacent syscalls:
87
+ // (a) Slot: between the lstat identity check and unlinkSync(slot), a live
88
+ // owner past HOOK_LOCK_STALE_MS could release and a new hook recreate the
89
+ // slot, whose fresh lock would then be deleted. The consequence is at
90
+ // most one extra concurrent augment beyond HOOK_LOCK_MAX_INFLIGHT for
91
+ // that run — the cap is a load guard, and no data or index state
92
+ // depends on it. The victim's release() sees a foreign or missing file
93
+ // and leaves it alone.
94
+ // (b) Marker: between the token re-read and unlinkSync(marker) (ours or an
95
+ // orphan's), the marker could pass to another claimant, whose claim would
96
+ // then be removed. That only re-opens the slot to one more evictor, which
97
+ // still has to pass the slot identity check before deleting anything.
98
+ // Both need a stall of seconds landing on that exact syscall pair, and the
99
+ // only thing lost is one run's cap accounting, so they are accepted rather
100
+ // than traded for heavier machinery. A crash at any point orphans at most the
101
+ // marker, which the next contender breaks after it expires.
102
+ function evictStaleSlot(slotPath, inspected) {
103
+ const marker = `${slotPath}.evicting`;
104
+ breakOrphanedMarker(marker);
105
+ const token = `${process.pid}:${crypto.randomBytes(8).toString('hex')}`;
106
+ try {
107
+ fs.writeFileSync(marker, token, { flag: 'wx' });
108
+ } catch {
109
+ return; // Another evictor holds this slot — leave it to that evictor.
110
+ }
111
+ try {
112
+ if (
113
+ readMarkerToken(marker) === token &&
114
+ sameSlotFile(fs.lstatSync(slotPath, { bigint: true }), inspected)
115
+ ) {
116
+ fs.unlinkSync(slotPath);
117
+ }
118
+ } catch {
119
+ /* slot already gone — the retry claims it */
120
+ } finally {
121
+ if (readMarkerToken(marker) === token) {
122
+ try {
123
+ fs.unlinkSync(marker);
124
+ } catch {
125
+ /* already gone */
126
+ }
127
+ }
128
+ }
129
+ }
130
+
8
131
  function acquireHookSlot(gitNexusDir) {
9
132
  const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
10
133
  try {
@@ -28,6 +151,7 @@ function acquireHookSlot(gitNexusDir) {
28
151
  const release = () => {
29
152
  if (released) return;
30
153
  released = true;
154
+ process.removeListener('exit', release);
31
155
  try {
32
156
  // Only unlink if we still own the slot. If we appeared stale and
33
157
  // another hook took over, the file now belongs to it — leave alone.
@@ -52,8 +176,10 @@ function acquireHookSlot(gitNexusDir) {
52
176
  }
53
177
  let isLive = false;
54
178
  let mtimeMs = Date.now();
179
+ let inspected = null;
55
180
  try {
56
- mtimeMs = fs.fstatSync(fd).mtimeMs;
181
+ inspected = fs.fstatSync(fd, { bigint: true });
182
+ mtimeMs = Number(inspected.mtimeMs);
57
183
  const buf = Buffer.alloc(32);
58
184
  const n = fs.readSync(fd, buf, 0, 32, 0);
59
185
  const ownerStr = buf.slice(0, n).toString('utf-8').trim();
@@ -98,11 +224,9 @@ function acquireHookSlot(gitNexusDir) {
98
224
  isLive = false;
99
225
  }
100
226
  if (isLive) break; // Try the next slot.
101
- try {
102
- fs.unlinkSync(slotPath);
103
- } catch {
104
- /* another hook beat us to it — retry will hit EEXIST */
105
- }
227
+ // No stat means we cannot prove which file we judged stale; leave it
228
+ // (the retry re-inspects it) rather than risk deleting a fresh lock.
229
+ if (inspected) evictStaleSlot(slotPath, inspected);
106
230
  // Loop and retry this slot.
107
231
  }
108
232
  }