xtrm-tools 2.1.13 → 2.1.16

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.
@@ -6,57 +6,28 @@
6
6
  //
7
7
  // Installed by: xtrm install
8
8
 
9
- import { readFileSync } from 'node:fs';
10
9
  import {
11
- resolveCwd, isBeadsProject, getSessionClaim,
12
- getInProgress, withSafeBdContext,
13
- } from './beads-gate-utils.mjs';
10
+ readHookInput,
11
+ resolveSessionContext,
12
+ resolveClaimAndWorkState,
13
+ decideStopGate,
14
+ } from './beads-gate-core.mjs';
15
+ import { withSafeBdContext } from './beads-gate-utils.mjs';
16
+ import { stopBlockMessage } from './beads-gate-messages.mjs';
14
17
 
15
- let input;
16
- try {
17
- input = JSON.parse(readFileSync(0, 'utf8'));
18
- } catch {
19
- process.exit(0);
20
- }
21
-
22
- const CLOSE_PROTOCOL =
23
- ' 3. bd close <id1> <id2> ... close all in_progress issues\n' +
24
- ' 4. git add <files> && git commit -m "..." commit your changes\n' +
25
- ' 5. git push -u origin <feature-branch> push feature branch\n' +
26
- ' 6. gh pr create --fill create PR\n' +
27
- ' 7. gh pr merge --squash merge PR\n' +
28
- ' 8. git checkout master && git reset --hard origin/master\n';
18
+ const input = readHookInput();
19
+ if (!input) process.exit(0);
29
20
 
30
21
  withSafeBdContext(() => {
31
- const cwd = resolveCwd(input);
32
- if (!isBeadsProject(cwd)) process.exit(0);
33
-
34
- const sessionId = input.session_id;
35
-
36
- if (sessionId) {
37
- const claimed = getSessionClaim(sessionId, cwd);
38
- if (claimed === null) process.exit(0); // bd kv unavailable — fail open
39
- if (!claimed) process.exit(0); // no active claim for this session
22
+ const ctx = resolveSessionContext(input);
23
+ if (!ctx || !ctx.isBeadsProject) process.exit(0);
40
24
 
41
- const ip = getInProgress(cwd);
42
- if (ip === null || ip.count === 0) process.exit(0); // claim is stale — allow stop
43
- const summary = ip?.summary ?? ` Claimed: ${claimed}`;
25
+ const state = resolveClaimAndWorkState(ctx);
26
+ const decision = decideStopGate(ctx, state);
44
27
 
45
- process.stderr.write(
46
- '🚫 BEADS STOP GATE: Unresolved issues — complete the session close protocol.\n\n' +
47
- `Open issues:\n${summary}\n\n` +
48
- 'Session close protocol:\n' + CLOSE_PROTOCOL
49
- );
50
- process.exit(2);
51
- } else {
52
- const ip = getInProgress(cwd);
53
- if (ip === null || ip.count === 0) process.exit(0);
28
+ if (decision.allow) process.exit(0);
54
29
 
55
- process.stderr.write(
56
- '🚫 BEADS STOP GATE: Unresolved issues — complete the session close protocol.\n\n' +
57
- `Open issues:\n${ip.summary}\n\n` +
58
- 'Session close protocol:\n' + CLOSE_PROTOCOL
59
- );
60
- process.exit(2);
61
- }
30
+ // Block with message
31
+ process.stderr.write(stopBlockMessage(decision.summary, decision.claimed));
32
+ process.exit(2);
62
33
  });
@@ -1,133 +1,222 @@
1
- #!/usr/bin/env node
2
- /**
3
- * GitNexus Claude Code Hook
4
- *
5
- * PreToolUse handler — intercepts Grep/Glob/Bash searches
6
- * and augments with graph context from the GitNexus index.
7
- *
8
- * NOTE: SessionStart hooks are broken on Windows (Claude Code bug).
9
- * Session context is injected via CLAUDE.md / skills instead.
10
- */
11
-
12
- const fs = require('fs');
13
- const path = require('path');
14
- const { execFileSync } = require('child_process');
15
-
16
- /**
17
- * Read JSON input from stdin synchronously.
18
- */
19
- function readInput() {
20
- try {
21
- const data = fs.readFileSync(0, 'utf-8');
22
- return JSON.parse(data);
23
- } catch {
24
- return {};
25
- }
26
- }
27
-
28
- /**
29
- * Check if a directory (or ancestor) has a .gitnexus index.
30
- */
31
- function findGitNexusIndex(startDir) {
32
- let dir = startDir || process.cwd();
33
- for (let i = 0; i < 5; i++) {
34
- if (fs.existsSync(path.join(dir, '.gitnexus'))) {
35
- return true;
36
- }
37
- const parent = path.dirname(dir);
38
- if (parent === dir) break;
39
- dir = parent;
40
- }
41
- return false;
42
- }
43
-
44
- /**
45
- * Extract search pattern from tool input.
46
- */
47
- function extractPattern(toolName, toolInput) {
48
- if (toolName === 'Grep') {
49
- return toolInput.pattern || null;
50
- }
51
-
52
- if (toolName === 'Glob') {
53
- const raw = toolInput.pattern || '';
54
- const match = raw.match(/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/);
55
- return match ? match[1] : null;
56
- }
57
-
58
- if (toolName === 'Bash') {
59
- const cmd = toolInput.command || '';
60
- if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
61
-
62
- const tokens = cmd.split(/\s+/);
63
- let foundCmd = false;
64
- let skipNext = false;
65
- const flagsWithValues = new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-g', '--glob', '-t', '--type', '--include', '--exclude']);
66
-
67
- for (const token of tokens) {
68
- if (skipNext) { skipNext = false; continue; }
69
- if (!foundCmd) {
70
- if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
71
- continue;
72
- }
73
- if (token.startsWith('-')) {
74
- if (flagsWithValues.has(token)) skipNext = true;
75
- continue;
76
- }
77
- const cleaned = token.replace(/['"]/g, '');
78
- return cleaned.length >= 3 ? cleaned : null;
79
- }
80
- return null;
81
- }
82
-
83
- return null;
84
- }
85
-
86
- function main() {
87
- try {
88
- const input = readInput();
89
- const hookEvent = input.hook_event_name || '';
90
-
91
- if (hookEvent !== 'PreToolUse') return;
92
-
93
- const cwd = input.cwd || process.cwd();
94
- if (!findGitNexusIndex(cwd)) return;
95
-
96
- const toolName = input.tool_name || '';
97
- const toolInput = input.tool_input || {};
98
-
99
- if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Bash') return;
100
-
101
- const pattern = extractPattern(toolName, toolInput);
102
- if (!pattern || pattern.length < 3) return;
103
-
104
- // Resolve CLI path relative to this hook script (same package)
105
- // hooks/claude/gitnexus-hook.cjs dist/cli/index.js
106
- // augment CLI writes result to stderr (KuzuDB's native module captures
107
- // stdout fd at OS level, making it unusable in subprocess contexts).
108
- const { spawnSync } = require('child_process');
109
- let result = '';
110
- try {
111
- const child = spawnSync(
112
- 'gitnexus',
113
- ['augment', pattern],
114
- { encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
115
- );
116
- result = child.stderr || '';
117
- } catch { /* graceful failure */ }
118
-
119
- if (result && result.trim()) {
120
- console.log(JSON.stringify({
121
- hookSpecificOutput: {
122
- hookEventName: 'PreToolUse',
123
- additionalContext: result.trim()
124
- }
125
- }));
126
- }
127
- } catch (err) {
128
- // Graceful failure — log to stderr for debugging
129
- console.error('GitNexus hook error:', err.message);
130
- }
131
- }
132
-
133
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitNexus Claude Code Hook — PostToolUse enrichment
4
+ *
5
+ * Fires AFTER Read/Grep/Glob/Bash/Serena tools complete.
6
+ * Extracts patterns from both tool input AND output, runs
7
+ * `gitnexus augment <pattern>`, and injects a [GitNexus: ...]
8
+ * block into Claude's context mirroring pi-gitnexus behavior.
9
+ *
10
+ * Features:
11
+ * - PostToolUse (vs old PreToolUse) — enriches actual result
12
+ * - Session-keyed dedup cache (/tmp) — no redundant lookups
13
+ * - Pattern extraction from output content (grep-style scanning)
14
+ * - Serena tool support — extracts symbol names from name_path
15
+ * - Graceful failure — always exits 0
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+ const { spawnSync } = require('child_process');
24
+
25
+ const CODE_EXTS = new Set([
26
+ '.ts', '.tsx', '.js', '.mjs', '.cjs', '.py', '.go', '.rs',
27
+ '.java', '.kt', '.cpp', '.c', '.h', '.rb', '.php', '.cs',
28
+ ]);
29
+
30
+ const SERENA_SYMBOL_TOOLS = new Set([
31
+ 'mcp__serena__find_symbol',
32
+ 'mcp__serena__find_referencing_symbols',
33
+ 'mcp__serena__replace_symbol_body',
34
+ 'mcp__serena__insert_after_symbol',
35
+ 'mcp__serena__insert_before_symbol',
36
+ ]);
37
+
38
+ const SERENA_FILE_TOOLS = new Set([
39
+ 'mcp__serena__get_symbols_overview',
40
+ 'mcp__serena__search_for_pattern',
41
+ ]);
42
+
43
+ const SERENA_RENAME = 'mcp__serena__rename_symbol';
44
+
45
+ function readInput() {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(0, 'utf-8'));
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ function findGitNexusIndex(startDir) {
54
+ let dir = startDir || process.cwd();
55
+ for (let i = 0; i < 6; i++) {
56
+ if (fs.existsSync(path.join(dir, '.gitnexus'))) return true;
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir) break;
59
+ dir = parent;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ function getCacheFile(sessionId) {
65
+ const id = sessionId ? sessionId.replace(/[^a-zA-Z0-9_-]/g, '_') : 'default';
66
+ return path.join(os.tmpdir(), `gitnexus-aug-${id}`);
67
+ }
68
+
69
+ function loadCache(cacheFile) {
70
+ try {
71
+ return new Set(fs.readFileSync(cacheFile, 'utf-8').split('\n').filter(Boolean));
72
+ } catch {
73
+ return new Set();
74
+ }
75
+ }
76
+
77
+ function saveToCache(cacheFile, pattern) {
78
+ try {
79
+ fs.appendFileSync(cacheFile, pattern + '\n');
80
+ } catch { /* graceful */ }
81
+ }
82
+
83
+ function symbolFromNamePath(namePath) {
84
+ if (!namePath) return null;
85
+ const parts = namePath.split('/').filter(Boolean);
86
+ const last = parts[parts.length - 1];
87
+ return last ? last.replace(/\[\d+\]$/, '') : null;
88
+ }
89
+
90
+ function symbolFromFilePath(filePath) {
91
+ if (!filePath) return null;
92
+ const ext = path.extname(filePath);
93
+ if (!CODE_EXTS.has(ext)) return null;
94
+ return path.basename(filePath, ext);
95
+ }
96
+
97
+ function extractFilePatternsFromOutput(content) {
98
+ if (!content || typeof content !== 'string') return [];
99
+ const patterns = new Set();
100
+ const lines = content.split('\n').slice(0, 50);
101
+ for (const line of lines) {
102
+ const m = line.match(/^([^\s:]+\.[a-z]{1,5}):\d+:/);
103
+ if (m) {
104
+ const ext = path.extname(m[1]);
105
+ if (CODE_EXTS.has(ext)) patterns.add(path.basename(m[1], ext));
106
+ }
107
+ }
108
+ return [...patterns];
109
+ }
110
+
111
+ function extractPatterns(toolName, toolInput, toolResponse) {
112
+ const patterns = [];
113
+ const content = typeof toolResponse === 'string'
114
+ ? toolResponse
115
+ : (toolResponse?.content ?? toolResponse?.output ?? '');
116
+
117
+ if (toolName === 'Read') {
118
+ const sym = symbolFromFilePath(toolInput.file_path);
119
+ if (sym) patterns.push(sym);
120
+ }
121
+
122
+ if (toolName === 'Grep') {
123
+ const raw = toolInput.pattern || '';
124
+ const cleaned = raw.replace(/[.*+?^${}()|[\]\\]/g, '').trim();
125
+ if (cleaned.length >= 3) patterns.push(cleaned);
126
+ patterns.push(...extractFilePatternsFromOutput(content));
127
+ }
128
+
129
+ if (toolName === 'Glob') {
130
+ const raw = toolInput.pattern || '';
131
+ const m = raw.match(/([a-zA-Z][a-zA-Z0-9_-]{2,})/);
132
+ if (m) patterns.push(m[1]);
133
+ patterns.push(...extractFilePatternsFromOutput(
134
+ Array.isArray(toolResponse) ? toolResponse.join('\n') : String(toolResponse || '')
135
+ ));
136
+ }
137
+
138
+ if (toolName === 'Bash') {
139
+ const cmd = toolInput.command || '';
140
+ if (!/\brg\b|\bgrep\b/.test(cmd)) return [];
141
+ const tokens = cmd.split(/\s+/);
142
+ let foundCmd = false, skipNext = false;
143
+ const flagsWithValues = new Set(['-e','-f','-m','-A','-B','-C','-g','--glob','-t','--type','--include','--exclude']);
144
+ for (const token of tokens) {
145
+ if (skipNext) { skipNext = false; continue; }
146
+ if (!foundCmd) { if (/\brg$|\bgrep$/.test(token)) foundCmd = true; continue; }
147
+ if (token.startsWith('-')) { if (flagsWithValues.has(token)) skipNext = true; continue; }
148
+ const cleaned = token.replace(/['"]/g, '').replace(/[.*+?^${}()|[\]\\]/g, '').trim();
149
+ if (cleaned.length >= 3) { patterns.push(cleaned); break; }
150
+ }
151
+ patterns.push(...extractFilePatternsFromOutput(content));
152
+ }
153
+
154
+ if (SERENA_SYMBOL_TOOLS.has(toolName)) {
155
+ const sym = symbolFromNamePath(toolInput.name_path_pattern || toolInput.name_path);
156
+ if (sym) patterns.push(sym);
157
+ }
158
+
159
+ if (toolName === SERENA_RENAME) {
160
+ if (toolInput.symbol_name) patterns.push(toolInput.symbol_name);
161
+ }
162
+
163
+ if (SERENA_FILE_TOOLS.has(toolName)) {
164
+ const sym = symbolFromFilePath(toolInput.relative_path || toolInput.file_path);
165
+ if (sym) patterns.push(sym);
166
+ const sub = toolInput.substring || toolInput.pattern || '';
167
+ const cleaned = sub.replace(/[.*+?^${}()|[\]\\]/g, '').trim();
168
+ if (cleaned.length >= 3) patterns.push(cleaned);
169
+ }
170
+
171
+ return [...new Set(patterns.filter(p => p && p.length >= 3))];
172
+ }
173
+
174
+ function runAugment(pattern, cwd) {
175
+ try {
176
+ const child = spawnSync('gitnexus', ['augment', pattern], {
177
+ encoding: 'utf-8',
178
+ timeout: 8000,
179
+ cwd,
180
+ stdio: ['pipe', 'pipe', 'pipe'],
181
+ });
182
+ return (child.stderr || '').trim();
183
+ } catch {
184
+ return '';
185
+ }
186
+ }
187
+
188
+ function main() {
189
+ try {
190
+ const input = readInput();
191
+ if (input.hook_event_name !== 'PostToolUse') return;
192
+
193
+ const cwd = input.cwd || process.cwd();
194
+ if (!findGitNexusIndex(cwd)) return;
195
+
196
+ const toolName = input.tool_name || '';
197
+ const toolInput = input.tool_input || {};
198
+ const toolResponse = input.tool_response ?? input.tool_result ?? '';
199
+
200
+ const patterns = extractPatterns(toolName, toolInput, toolResponse);
201
+ if (patterns.length === 0) return;
202
+
203
+ const cacheFile = getCacheFile(input.session_id);
204
+ const cache = loadCache(cacheFile);
205
+
206
+ const results = [];
207
+ for (const pattern of patterns) {
208
+ if (cache.has(pattern)) continue;
209
+ const out = runAugment(pattern, cwd);
210
+ saveToCache(cacheFile, pattern);
211
+ if (out) results.push(out);
212
+ }
213
+
214
+ if (results.length > 0) {
215
+ process.stdout.write('[GitNexus]\n' + results.join('\n\n') + '\n');
216
+ }
217
+ } catch (err) {
218
+ process.stderr.write('GitNexus hook error: ' + err.message + '\n');
219
+ }
220
+ }
221
+
222
+ main();
@@ -86,6 +86,26 @@ if (tool === 'Bash') {
86
86
  process.exit(0);
87
87
  }
88
88
 
89
+ // Enforce squash-only PR merges for linear history
90
+ // Must check BEFORE the gh allowlist pattern
91
+ if (/^gh\s+pr\s+merge\b/.test(cmd)) {
92
+ if (!/--squash\b/.test(cmd)) {
93
+ deny(
94
+ `\u26D4 Only squash merges are allowed — use 'gh pr merge --squash'\n\n` +
95
+ 'Why squash?\n' +
96
+ ' - Keeps history linear and easy to read\n' +
97
+ ' - One commit per PR = easy to revert\n' +
98
+ ' - Matches the workflow documented in AGENTS.md\n\n' +
99
+ 'Correct usage:\n' +
100
+ ' gh pr merge --squash\n\n' +
101
+ 'If you really need a merge commit, use:\n' +
102
+ ' MAIN_GUARD_ALLOW_BASH=1 gh pr merge --merge\n'
103
+ );
104
+ }
105
+ // --squash present — allow
106
+ process.exit(0);
107
+ }
108
+
89
109
  // Safe allowlist — non-mutating commands + explicit branch-exit paths.
90
110
  // Important: do not allow generic checkout/switch forms, which include
91
111
  // mutating variants such as `git checkout -- <path>`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "2.1.13",
3
+ "version": "2.1.16",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "license": "MIT",
6
6
  "type": "module",