Skip to content

Commit e6675f5

Browse files
authored
Merge pull request #4 from syntaxPriest/update_tool_description
Stale graph invalidation and tool invocatione ncouragement
2 parents 2488a46 + 6aeb2fe commit e6675f5

2 files changed

Lines changed: 146 additions & 29 deletions

File tree

mcp/hooks/openvisio-gate.mjs

Lines changed: 142 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
#!/usr/bin/env node
2-
// openvisio-gate — PreToolUse hook that ENFORCES "openvisio first".
2+
// openvisio-gate — PreToolUse hook that ENFORCES "openvisio first" PER TASK.
33
//
44
// An MCP server's `instructions` field is only advisory; the model can ignore
5-
// it. This hook is the deterministic layer: it hard-blocks code-discovery tools
6-
// (Read / Grep / Glob, and grep/find/rg/ag/cat/sed/awk via Bash) until an
7-
// openvisio MCP tool has been called at least once in the session. The first
8-
// openvisio call "primes" the session; after that every tool passes (so the
9-
// agent can read the anchored slices the graph pointed it at).
5+
// it. This hook is the deterministic layer:
106
//
11-
// Why prime-once-per-session and not per-read: a hook cannot see task
12-
// boundaries, and gating every read forever would make anchored-slice reads
13-
// impossible. Priming forces openvisio-FIRST deterministically; the strengthened
14-
// server `instructions` carry the "every task" expectation from there.
7+
// • Write Bash commands (edits, builds, commits) signal a task boundary and
8+
// clear the "primed" marker, forcing the agent to call resolve_context
9+
// again on the next task.
10+
// • Write commands also delete .openvisio/graph.json so the viewer
11+
// re-indexes on the next request.
12+
// • Read/Grep/Glob for non-code files (configs, docs, lockfiles) are always
13+
// allowed without priming.
14+
// • All other code-discovery tools (Read/Grep/Glob on code, grep/find/etc.
15+
// in Bash) are DENIED until an openvisio tool primes the session.
16+
//
17+
// The first openvisio tool call in each task "primes" the session; after that
18+
// all tools pass (anchored reads, git status, etc. all flow freely).
1519
//
1620
// Install in the repo you point the agent at — .claude/settings.json:
1721
// {
@@ -30,10 +34,14 @@
3034
// The matcher MUST include `mcp__openvisio__.*` so the hook also sees openvisio
3135
// calls and can prime the session.
3236

33-
import { readFileSync, existsSync, writeFileSync } from 'node:fs'
37+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
3438
import { tmpdir } from 'node:os'
3539
import { join } from 'node:path'
3640

41+
// ---------------------------------------------------------------------------
42+
// Helpers
43+
// ---------------------------------------------------------------------------
44+
3745
function readStdin() {
3846
try {
3947
return readFileSync(0, 'utf8')
@@ -42,54 +50,159 @@ function readStdin() {
4250
}
4351
}
4452

53+
/** Known READ-ONLY Bash commands — everything else is treated as a write
54+
* (task-boundary signal). */
55+
const READONLY_BASH = /\b(grep|rg|ag|ack|find|cat|head|tail|wc|diff|echo|pwd|which|type|file|du|df|ls)\b/
56+
57+
/** Bash commands that do code search — these are GATED (denied before prime)
58+
* just like Read/Grep/Glob. A subset of non-readonly Bash. */
59+
const SEARCH_BASH = /\b(grep|rg|ag|ack|find|cat|sed|awk|head|tail|ls)\b/
60+
61+
/** File extensions that are never code — reading these is always allowed. */
62+
const NON_CODE_EXT = /\.(md|json|yaml|yml|toml|lock|txt|cfg|ini|env|gitignore|dockerignore|svg|png|jpg|jpeg|gif|ico|woff2?|ttf|eot|csv|sql|log)$/i
63+
64+
function isNonCodeRead(toolName, toolInput) {
65+
if (toolName === 'Read') {
66+
const path = String(toolInput?.file_path || toolInput?.path || '')
67+
return NON_CODE_EXT.test(path)
68+
}
69+
if (toolName === 'Grep') {
70+
// Grep config/docs files — typically --include or a path ending in a
71+
// non-code extension in the second arg. Heuristic: if the pattern itself
72+
// is short and not a code construct, allow. Simpler: always allow Grep
73+
// on paths that look like non-code.
74+
const include = String(toolInput?.include || '')
75+
const path = String(toolInput?.path || '')
76+
return NON_CODE_EXT.test(include || path)
77+
}
78+
if (toolName === 'Glob') {
79+
const pattern = String(toolInput?.pattern || '')
80+
return NON_CODE_EXT.test(pattern)
81+
}
82+
return false
83+
}
84+
85+
function deleteIfExists(file) {
86+
try {
87+
if (existsSync(file)) unlinkSync(file)
88+
} catch {
89+
/* best-effort */
90+
}
91+
}
92+
93+
function repoRoot() {
94+
// Walk up from cwd looking for .git or .openvisio.
95+
let dir = process.cwd()
96+
if (!dir.endsWith('/')) dir += '/'
97+
for (let i = 0; i < 20; i++) {
98+
if (existsSync(join(dir, '.git')) || existsSync(join(dir, '.openvisio'))) {
99+
return dir.replace(/\/$/, '')
100+
}
101+
const parent = join(dir, '..')
102+
if (parent === dir || parent.length >= dir.length) break
103+
dir = parent
104+
}
105+
return process.cwd()
106+
}
107+
108+
// ---------------------------------------------------------------------------
109+
// Parse hook input
110+
// ---------------------------------------------------------------------------
111+
45112
let input = {}
46113
try {
47114
input = JSON.parse(readStdin())
48115
} catch {
49-
// Malformed hook payload → fail open (never wedge the agent).
50116
process.exit(0)
51117
}
52118

53119
const sessionId = String(input.session_id || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '')
54120
const marker = join(tmpdir(), `openvisio-primed-${sessionId}`)
55121
const toolName = String(input.tool_name || '')
122+
const toolInput = input.tool_input || {}
123+
124+
// ---------------------------------------------------------------------------
125+
// Rule 1: openvisio MCP tools always pass AND prime the session.
126+
// ---------------------------------------------------------------------------
56127

57-
// Any openvisio tool primes the session and always passes.
58128
if (toolName.startsWith('mcp__openvisio__')) {
59129
try {
60130
writeFileSync(marker, '1')
61131
} catch {
62-
/* best-effort; failing to write the marker only costs one extra nudge */
132+
/* best-effort */
63133
}
64134
process.exit(0)
65135
}
66136

67-
// Already primed → trust the agent (it may read anchored slices, run git, etc.).
68-
if (existsSync(marker)) process.exit(0)
137+
// ---------------------------------------------------------------------------
138+
// Rule 2: Bash write commands signal a task boundary.
139+
// Clear the prime + delete stale graph.json.
140+
// ---------------------------------------------------------------------------
69141

70-
// Pre-prime: is this a code-discovery action we gate?
71-
const GATED_TOOLS = new Set(['Read', 'Grep', 'Glob'])
72-
let gated = GATED_TOOLS.has(toolName)
73142
if (toolName === 'Bash') {
74-
const cmd = String(input.tool_input?.command || '')
75-
// Bash loopholes for code search: grep/rg/ag/ack/find/cat/sed/awk/head/tail/ls.
76-
gated = /\b(grep|rg|ag|ack|find|cat|sed|awk|head|tail|ls)\b/.test(cmd)
143+
const cmd = String(toolInput.command || '')
144+
145+
// Not a write command → pass through (may still be gated as search below).
146+
if (!READONLY_BASH.test(cmd)) {
147+
// Write command detected — clear the prime marker for per-task gating.
148+
deleteIfExists(marker)
149+
// Also blow away the viewer's stale cached graph.
150+
deleteIfExists(join(repoRoot(), '.openvisio', 'graph.json'))
151+
}
152+
153+
// Gate search commands before priming (same as pre-prime Read/Grep/Glob).
154+
if (existsSync(marker)) process.exit(0)
155+
if (SEARCH_BASH.test(cmd)) {
156+
process.stdout.write(
157+
JSON.stringify({
158+
hookSpecificOutput: {
159+
hookEventName: 'PreToolUse',
160+
permissionDecision: 'deny',
161+
permissionDecisionReason:
162+
'openvisio-gate: call the openvisio MCP first. Before searching code, ' +
163+
'call `resolve_context` with your task (then find_symbol / get_neighborhood ' +
164+
'/ get_dependents) to get path:line anchors. Then retry this action.',
165+
},
166+
}),
167+
)
168+
process.exit(0)
169+
}
170+
process.exit(0)
77171
}
78172

79-
if (!gated) process.exit(0)
173+
// ---------------------------------------------------------------------------
174+
// Rule 3: Already primed for this task → everything passes.
175+
// ---------------------------------------------------------------------------
176+
177+
if (existsSync(marker)) process.exit(0)
178+
179+
// ---------------------------------------------------------------------------
180+
// Rule 4: Ungated tools (non-Bash, non-Read/Grep/Glob) pass through.
181+
// ---------------------------------------------------------------------------
182+
183+
const GATED_TOOLS = new Set(['Read', 'Grep', 'Glob'])
184+
if (!GATED_TOOLS.has(toolName)) process.exit(0)
185+
186+
// ---------------------------------------------------------------------------
187+
// Rule 5: Non-code file reads are always allowed.
188+
// ---------------------------------------------------------------------------
189+
190+
if (isNonCodeRead(toolName, toolInput)) process.exit(0)
80191

81-
const reason =
82-
'openvisio-gate: call the openvisio MCP first. Before reading, grepping, or ' +
83-
'globbing any file in this repo, call `resolve_context` with your task (then ' +
84-
'find_symbol / get_neighborhood / get_dependents) to get path:line anchors. ' +
85-
'Then retry this action.'
192+
// ---------------------------------------------------------------------------
193+
// Rule 6: Deny — call openvisio first.
194+
// ---------------------------------------------------------------------------
86195

87196
process.stdout.write(
88197
JSON.stringify({
89198
hookSpecificOutput: {
90199
hookEventName: 'PreToolUse',
91200
permissionDecision: 'deny',
92-
permissionDecisionReason: reason,
201+
permissionDecisionReason:
202+
'openvisio-gate: call the openvisio MCP first. Before reading, grepping, or ' +
203+
'globbing any file in this repo, call `resolve_context` with your task (then ' +
204+
'find_symbol / get_neighborhood / get_dependents) to get path:line anchors. ' +
205+
'Then retry this action.',
93206
},
94207
}),
95208
)

ui/app/api/local-graph/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ function cliPath(): string {
1616
return process.env.OPENVISIO_CLI || path.resolve(process.cwd(), '..', 'mcp', 'dist', 'cli.js')
1717
}
1818

19+
const GRAPH_TTL_MS = Number(process.env.OPENVISIO_GRAPH_TTL_MIN || '60') * 60 * 1000
20+
1921
function isGraphFresh(outFile: string, repoPath: string): boolean {
2022
try {
2123
const graphStat = fs.statSync(outFile)
24+
// TTL: if the cached graph is older than the max age, treat as stale.
25+
if (Date.now() - graphStat.mtimeMs > GRAPH_TTL_MS) return false
2226
// Compare against .git/index which git touches on any staged/committed change.
2327
// If .git/index doesn't exist (bare checkout, tarball), treat as stale.
2428
const ref = path.join(repoPath, '.git', 'index')

0 commit comments

Comments
 (0)