context-mode 0.9.21 → 1.0.0

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 (102) hide show
  1. package/.claude-plugin/hooks/hooks.json +46 -4
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/.claude-plugin/plugin.json +4 -4
  4. package/README.md +377 -191
  5. package/build/adapters/claude-code/config.d.ts +8 -0
  6. package/build/adapters/claude-code/config.js +8 -0
  7. package/build/adapters/claude-code/hooks.d.ts +53 -0
  8. package/build/adapters/claude-code/hooks.js +88 -0
  9. package/build/adapters/claude-code/index.d.ts +50 -0
  10. package/build/adapters/claude-code/index.js +523 -0
  11. package/build/adapters/codex/config.d.ts +8 -0
  12. package/build/adapters/codex/config.js +8 -0
  13. package/build/adapters/codex/hooks.d.ts +21 -0
  14. package/build/adapters/codex/hooks.js +27 -0
  15. package/build/adapters/codex/index.d.ts +44 -0
  16. package/build/adapters/codex/index.js +223 -0
  17. package/build/adapters/detect.d.ts +26 -0
  18. package/build/adapters/detect.js +131 -0
  19. package/build/adapters/gemini-cli/config.d.ts +8 -0
  20. package/build/adapters/gemini-cli/config.js +8 -0
  21. package/build/adapters/gemini-cli/hooks.d.ts +44 -0
  22. package/build/adapters/gemini-cli/hooks.js +64 -0
  23. package/build/adapters/gemini-cli/index.d.ts +57 -0
  24. package/build/adapters/gemini-cli/index.js +468 -0
  25. package/build/adapters/opencode/config.d.ts +8 -0
  26. package/build/adapters/opencode/config.js +8 -0
  27. package/build/adapters/opencode/hooks.d.ts +38 -0
  28. package/build/adapters/opencode/hooks.js +50 -0
  29. package/build/adapters/opencode/index.d.ts +52 -0
  30. package/build/adapters/opencode/index.js +386 -0
  31. package/build/adapters/types.d.ts +218 -0
  32. package/build/adapters/types.js +13 -0
  33. package/build/adapters/vscode-copilot/config.d.ts +8 -0
  34. package/build/adapters/vscode-copilot/config.js +8 -0
  35. package/build/adapters/vscode-copilot/hooks.d.ts +49 -0
  36. package/build/adapters/vscode-copilot/hooks.js +76 -0
  37. package/build/adapters/vscode-copilot/index.d.ts +58 -0
  38. package/build/adapters/vscode-copilot/index.js +512 -0
  39. package/build/cli.d.ts +9 -6
  40. package/build/cli.js +133 -423
  41. package/build/db-base.d.ts +84 -0
  42. package/build/db-base.js +128 -0
  43. package/build/executor.d.ts +6 -7
  44. package/build/executor.js +111 -51
  45. package/build/opencode-plugin.d.ts +37 -0
  46. package/build/opencode-plugin.js +118 -0
  47. package/build/runtime.js +1 -1
  48. package/build/server.js +436 -117
  49. package/build/session/db.d.ts +110 -0
  50. package/build/session/db.js +285 -0
  51. package/build/session/extract.d.ts +51 -0
  52. package/build/session/extract.js +407 -0
  53. package/build/session/snapshot.d.ts +70 -0
  54. package/build/session/snapshot.js +309 -0
  55. package/build/store.d.ts +4 -22
  56. package/build/store.js +67 -55
  57. package/build/truncate.d.ts +59 -0
  58. package/build/truncate.js +157 -0
  59. package/build/types.d.ts +101 -0
  60. package/build/types.js +20 -0
  61. package/configs/claude-code/CLAUDE.md +62 -0
  62. package/configs/codex/AGENTS.md +58 -0
  63. package/configs/codex/config.toml +5 -0
  64. package/configs/gemini-cli/GEMINI.md +58 -0
  65. package/configs/gemini-cli/mcp.json +7 -0
  66. package/configs/gemini-cli/settings.json +49 -0
  67. package/configs/opencode/AGENTS.md +58 -0
  68. package/configs/opencode/opencode.json +10 -0
  69. package/configs/vscode-copilot/copilot-instructions.md +58 -0
  70. package/configs/vscode-copilot/hooks.json +16 -0
  71. package/configs/vscode-copilot/mcp.json +8 -0
  72. package/hooks/core/formatters.mjs +86 -0
  73. package/hooks/core/routing.mjs +262 -0
  74. package/hooks/core/stdin.mjs +19 -0
  75. package/hooks/formatters/claude-code.mjs +57 -0
  76. package/hooks/formatters/gemini-cli.mjs +55 -0
  77. package/hooks/formatters/vscode-copilot.mjs +55 -0
  78. package/hooks/gemini-cli/aftertool.mjs +58 -0
  79. package/hooks/gemini-cli/beforetool.mjs +25 -0
  80. package/hooks/gemini-cli/precompress.mjs +51 -0
  81. package/hooks/gemini-cli/sessionstart.mjs +117 -0
  82. package/hooks/hooks.json +46 -4
  83. package/hooks/posttooluse.mjs +53 -0
  84. package/hooks/precompact.mjs +55 -0
  85. package/hooks/pretooluse.mjs +23 -266
  86. package/hooks/routing-block.mjs +19 -6
  87. package/hooks/session-directive.mjs +353 -0
  88. package/hooks/session-helpers.mjs +112 -0
  89. package/hooks/sessionstart.mjs +123 -16
  90. package/hooks/userpromptsubmit.mjs +58 -0
  91. package/hooks/vscode-copilot/posttooluse.mjs +58 -0
  92. package/hooks/vscode-copilot/precompact.mjs +51 -0
  93. package/hooks/vscode-copilot/pretooluse.mjs +25 -0
  94. package/hooks/vscode-copilot/sessionstart.mjs +115 -0
  95. package/package.json +20 -17
  96. package/skills/context-mode/SKILL.md +49 -49
  97. package/skills/{doctor → ctx-doctor}/SKILL.md +3 -3
  98. package/skills/{stats → ctx-stats}/SKILL.md +3 -3
  99. package/skills/{upgrade → ctx-upgrade}/SKILL.md +3 -3
  100. package/start.mjs +47 -0
  101. package/hooks/pretooluse.sh +0 -147
  102. package/server.bundle.mjs +0 -341
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ { "type": "command", "command": "context-mode hook vscode-copilot pretooluse" }
5
+ ],
6
+ "PostToolUse": [
7
+ { "type": "command", "command": "context-mode hook vscode-copilot posttooluse" }
8
+ ],
9
+ "PreCompact": [
10
+ { "type": "command", "command": "context-mode hook vscode-copilot precompact" }
11
+ ],
12
+ "SessionStart": [
13
+ { "type": "command", "command": "context-mode hook vscode-copilot sessionstart" }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "servers": {
3
+ "context-mode": {
4
+ "command": "npx",
5
+ "args": ["-y", "context-mode"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Platform-specific response formatters.
3
+ * Takes normalized decision from routing.mjs -> platform-specific JSON output.
4
+ */
5
+
6
+ export const formatters = {
7
+ "claude-code": {
8
+ deny: (reason) => ({
9
+ hookSpecificOutput: {
10
+ hookEventName: "PreToolUse",
11
+ permissionDecision: "deny",
12
+ reason,
13
+ },
14
+ }),
15
+ ask: () => ({
16
+ hookSpecificOutput: {
17
+ hookEventName: "PreToolUse",
18
+ permissionDecision: "ask",
19
+ },
20
+ }),
21
+ modify: (updatedInput) => ({
22
+ hookSpecificOutput: {
23
+ hookEventName: "PreToolUse",
24
+ updatedInput,
25
+ },
26
+ }),
27
+ context: (additionalContext) => ({
28
+ hookSpecificOutput: {
29
+ hookEventName: "PreToolUse",
30
+ additionalContext,
31
+ },
32
+ }),
33
+ },
34
+
35
+ "gemini-cli": {
36
+ deny: (reason) => ({ decision: "deny", reason }),
37
+ ask: () => null, // Gemini CLI has no "ask" concept
38
+ modify: (updatedInput) => ({
39
+ hookSpecificOutput: { tool_input: updatedInput },
40
+ }),
41
+ context: (additionalContext) => ({
42
+ hookSpecificOutput: { additionalContext },
43
+ }),
44
+ },
45
+
46
+ "vscode-copilot": {
47
+ deny: (reason) => ({
48
+ permissionDecision: "deny",
49
+ reason,
50
+ }),
51
+ ask: () => ({
52
+ permissionDecision: "ask",
53
+ }),
54
+ modify: (updatedInput) => ({
55
+ hookSpecificOutput: {
56
+ hookEventName: "PreToolUse",
57
+ updatedInput,
58
+ },
59
+ }),
60
+ context: (additionalContext) => ({
61
+ hookSpecificOutput: {
62
+ hookEventName: "PreToolUse",
63
+ additionalContext,
64
+ },
65
+ }),
66
+ },
67
+ };
68
+
69
+ /**
70
+ * Apply a formatter to a normalized routing decision.
71
+ * Returns the platform-specific JSON response, or null for passthrough.
72
+ */
73
+ export function formatDecision(platform, decision) {
74
+ if (!decision) return null;
75
+
76
+ const fmt = formatters[platform];
77
+ if (!fmt) return null;
78
+
79
+ switch (decision.action) {
80
+ case "deny": return fmt.deny(decision.reason);
81
+ case "ask": return fmt.ask();
82
+ case "modify": return fmt.modify(decision.updatedInput);
83
+ case "context": return fmt.context(decision.additionalContext);
84
+ default: return null;
85
+ }
86
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Pure routing logic for PreToolUse hooks.
3
+ * Returns NORMALIZED decision objects (NOT platform-specific format).
4
+ *
5
+ * Decision types:
6
+ * - { action: "deny", reason: string }
7
+ * - { action: "ask" }
8
+ * - { action: "modify", updatedInput: object }
9
+ * - { action: "context", additionalContext: string }
10
+ * - null (passthrough)
11
+ */
12
+
13
+ import { ROUTING_BLOCK, READ_GUIDANCE, GREP_GUIDANCE, BASH_GUIDANCE } from "../routing-block.mjs";
14
+
15
+ /**
16
+ * Strip heredoc content from a shell command.
17
+ * Handles: <<EOF, <<"EOF", <<'EOF', <<-EOF (indented), with optional spaces.
18
+ */
19
+ function stripHeredocs(cmd) {
20
+ return cmd.replace(/<<-?\s*["']?(\w+)["']?[\s\S]*?\n\s*\1/g, "");
21
+ }
22
+
23
+ /**
24
+ * Strip ALL quoted content from a shell command so regex only matches command tokens.
25
+ * Removes heredocs, single-quoted strings, and double-quoted strings.
26
+ * This prevents false positives like: gh issue edit --body "text with curl in it"
27
+ */
28
+ function stripQuotedContent(cmd) {
29
+ return stripHeredocs(cmd)
30
+ .replace(/'[^']*'/g, "''") // single-quoted strings
31
+ .replace(/"[^"]*"/g, '""'); // double-quoted strings
32
+ }
33
+
34
+ // Try to import security module — may not exist
35
+ let security = null;
36
+
37
+ export async function initSecurity(buildDir) {
38
+ try {
39
+ const { pathToFileURL } = await import("node:url");
40
+ const secPath = (await import("node:path")).resolve(buildDir, "security.js");
41
+ security = await import(pathToFileURL(secPath).href);
42
+ } catch { /* not available */ }
43
+ }
44
+
45
+ /**
46
+ * Normalize platform-specific tool names to canonical (Claude Code) names.
47
+ *
48
+ * Evidence:
49
+ * - Gemini CLI: https://github.com/google-gemini/gemini-cli (run_shell_command, read_file, grep_search, web_fetch, activate_skill)
50
+ * - OpenCode: https://github.com/opencode-ai/opencode (bash, view, grep, fetch, agent)
51
+ * - Codex CLI: https://github.com/openai/codex (shell, read_file, grep_files, container.exec)
52
+ * - VS Code Copilot: tool names TBD — uses empty matcher until confirmed
53
+ */
54
+ const TOOL_ALIASES = {
55
+ // Gemini CLI
56
+ "run_shell_command": "Bash",
57
+ "read_file": "Read",
58
+ "read_many_files": "Read",
59
+ "grep_search": "Grep",
60
+ "search_file_content": "Grep",
61
+ "web_fetch": "WebFetch",
62
+ "activate_skill": "Agent",
63
+ // OpenCode
64
+ "bash": "Bash",
65
+ "view": "Read",
66
+ "grep": "Grep",
67
+ "fetch": "WebFetch",
68
+ "agent": "Agent",
69
+ // Codex CLI
70
+ "shell": "Bash",
71
+ "shell_command": "Bash",
72
+ "exec_command": "Bash",
73
+ "container.exec": "Bash",
74
+ "local_shell": "Bash",
75
+ "grep_files": "Grep",
76
+ };
77
+
78
+ /**
79
+ * Route a PreToolUse event. Returns normalized decision object or null for passthrough.
80
+ */
81
+ export function routePreToolUse(toolName, toolInput, projectDir) {
82
+ // Normalize platform-specific tool name to canonical
83
+ const canonical = TOOL_ALIASES[toolName] ?? toolName;
84
+
85
+ // ─── Bash: Stage 1 security check, then Stage 2 routing ───
86
+ if (canonical === "Bash") {
87
+ const command = toolInput.command ?? "";
88
+
89
+ // Stage 1: Security check against user's deny/allow patterns.
90
+ // Only act when an explicit pattern matched. When no pattern matches,
91
+ // evaluateCommand returns { decision: "ask" } with no matchedPattern —
92
+ // in that case fall through so other hooks and the platform's native engine can decide.
93
+ if (security) {
94
+ const policies = security.readBashPolicies(projectDir);
95
+ if (policies.length > 0) {
96
+ const result = security.evaluateCommand(command, policies);
97
+ if (result.decision === "deny") {
98
+ return { action: "deny", reason: `Blocked by security policy: matches deny pattern ${result.matchedPattern}` };
99
+ }
100
+ if (result.decision === "ask" && result.matchedPattern) {
101
+ return { action: "ask" };
102
+ }
103
+ // "allow" or no match → fall through to Stage 2
104
+ }
105
+ }
106
+
107
+ // Stage 2: Context-mode routing (existing behavior)
108
+
109
+ // curl/wget detection: strip quoted content first to avoid false positives
110
+ // like `gh issue edit --body "text with curl in it"` (Issue #63).
111
+ const stripped = stripQuotedContent(command);
112
+
113
+ // curl/wget → replace with echo redirect
114
+ if (/(^|\s|&&|\||\;)(curl|wget)\s/i.test(stripped)) {
115
+ return {
116
+ action: "modify",
117
+ updatedInput: {
118
+ command: 'echo "context-mode: curl/wget blocked. You MUST use mcp__plugin_context-mode_context-mode__ctx_fetch_and_index(url, source) to fetch URLs, or mcp__plugin_context-mode_context-mode__ctx_execute(language, code) to run HTTP calls in sandbox. Do NOT retry with curl/wget."',
119
+ },
120
+ };
121
+ }
122
+
123
+ // Inline HTTP detection: strip only heredocs (not quotes) so that
124
+ // code passed via -e/-c flags is still visible to the regex, while
125
+ // heredoc content (e.g. cat << EOF ... requests.get ... EOF) is removed.
126
+ // These patterns are specific enough that false positives in quoted
127
+ // text are rare, unlike single-word "curl"/"wget" (Issue #63).
128
+ const noHeredoc = stripHeredocs(command);
129
+ if (
130
+ /fetch\s*\(\s*['"](https?:\/\/|http)/i.test(noHeredoc) ||
131
+ /requests\.(get|post|put)\s*\(/i.test(noHeredoc) ||
132
+ /http\.(get|request)\s*\(/i.test(noHeredoc)
133
+ ) {
134
+ return {
135
+ action: "modify",
136
+ updatedInput: {
137
+ command: 'echo "context-mode: Inline HTTP blocked. Use mcp__plugin_context-mode_context-mode__ctx_execute(language, code) to run HTTP calls in sandbox, or mcp__plugin_context-mode_context-mode__ctx_fetch_and_index(url, source) for web pages. Do NOT retry with Bash."',
138
+ },
139
+ };
140
+ }
141
+
142
+ // Build tools (gradle, maven) → redirect to execute sandbox (Issue #38).
143
+ // These produce extremely verbose output that should stay in sandbox.
144
+ if (/(^|\s|&&|\||\;)(\.\/gradlew|gradlew|gradle|\.\/mvnw|mvnw|mvn)\s/i.test(stripped)) {
145
+ const safeCmd = command.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
146
+ return {
147
+ action: "modify",
148
+ updatedInput: {
149
+ command: `echo "context-mode: Build tool redirected to sandbox. Use mcp__plugin_context-mode_context-mode__ctx_execute(language: \\"shell\\", code: \\"${safeCmd}\\") to run this command. Do NOT retry with Bash."`,
150
+ },
151
+ };
152
+ }
153
+
154
+ // allow all other Bash commands, but inject routing nudge
155
+ return { action: "context", additionalContext: BASH_GUIDANCE };
156
+ }
157
+
158
+ // ─── Read: nudge toward execute_file ───
159
+ if (canonical === "Read") {
160
+ return { action: "context", additionalContext: READ_GUIDANCE };
161
+ }
162
+
163
+ // ─── Grep: nudge toward execute ───
164
+ if (canonical === "Grep") {
165
+ return { action: "context", additionalContext: GREP_GUIDANCE };
166
+ }
167
+
168
+ // ─── WebFetch: deny + redirect to sandbox ───
169
+ if (canonical === "WebFetch") {
170
+ const url = toolInput.url ?? "";
171
+ return {
172
+ action: "deny",
173
+ reason: `context-mode: WebFetch blocked. Use mcp__plugin_context-mode_context-mode__ctx_fetch_and_index(url: "${url}", source: "...") to fetch this URL in sandbox. Then use mcp__plugin_context-mode_context-mode__ctx_search(queries: [...]) to query results. Do NOT use curl/wget — they are also blocked.`,
174
+ };
175
+ }
176
+
177
+ // ─── Agent/Task: inject context-mode routing into subagent prompts ───
178
+ if (canonical === "Agent" || canonical === "Task") {
179
+ const subagentType = toolInput.subagent_type ?? "";
180
+ const prompt = toolInput.prompt ?? "";
181
+
182
+ const updatedInput =
183
+ subagentType === "Bash"
184
+ ? { ...toolInput, prompt: prompt + ROUTING_BLOCK, subagent_type: "general-purpose" }
185
+ : { ...toolInput, prompt: prompt + ROUTING_BLOCK };
186
+
187
+ return { action: "modify", updatedInput };
188
+ }
189
+
190
+ // ─── MCP execute: security check for shell commands ───
191
+ // Match both __execute and __ctx_execute (prefixed tool names)
192
+ if (toolName.includes("context-mode") && /__(ctx_)?execute$/.test(toolName)) {
193
+ if (security && toolInput.language === "shell") {
194
+ const code = toolInput.code ?? "";
195
+ const policies = security.readBashPolicies(projectDir);
196
+ if (policies.length > 0) {
197
+ const result = security.evaluateCommand(code, policies);
198
+ if (result.decision === "deny") {
199
+ return { action: "deny", reason: `Blocked by security policy: shell code matches deny pattern ${result.matchedPattern}` };
200
+ }
201
+ if (result.decision === "ask" && result.matchedPattern) {
202
+ return { action: "ask" };
203
+ }
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ // ─── MCP execute_file: check file path + code against deny patterns ───
210
+ if (toolName.includes("context-mode") && /__(ctx_)?execute_file$/.test(toolName)) {
211
+ if (security) {
212
+ // Check file path against Read deny patterns
213
+ const filePath = toolInput.path ?? "";
214
+ const denyGlobs = security.readToolDenyPatterns("Read", projectDir);
215
+ const evalResult = security.evaluateFilePath(filePath, denyGlobs);
216
+ if (evalResult.denied) {
217
+ return { action: "deny", reason: `Blocked by security policy: file path matches Read deny pattern ${evalResult.matchedPattern}` };
218
+ }
219
+
220
+ // Check code parameter against Bash deny patterns (same as execute)
221
+ const lang = toolInput.language ?? "";
222
+ const code = toolInput.code ?? "";
223
+ if (lang === "shell") {
224
+ const policies = security.readBashPolicies(projectDir);
225
+ if (policies.length > 0) {
226
+ const result = security.evaluateCommand(code, policies);
227
+ if (result.decision === "deny") {
228
+ return { action: "deny", reason: `Blocked by security policy: shell code matches deny pattern ${result.matchedPattern}` };
229
+ }
230
+ if (result.decision === "ask" && result.matchedPattern) {
231
+ return { action: "ask" };
232
+ }
233
+ }
234
+ }
235
+ }
236
+ return null;
237
+ }
238
+
239
+ // ─── MCP batch_execute: check each command individually ───
240
+ if (toolName.includes("context-mode") && /__(ctx_)?batch_execute$/.test(toolName)) {
241
+ if (security) {
242
+ const commands = toolInput.commands ?? [];
243
+ const policies = security.readBashPolicies(projectDir);
244
+ if (policies.length > 0) {
245
+ for (const entry of commands) {
246
+ const cmd = entry.command ?? "";
247
+ const result = security.evaluateCommand(cmd, policies);
248
+ if (result.decision === "deny") {
249
+ return { action: "deny", reason: `Blocked by security policy: batch command "${entry.label ?? cmd}" matches deny pattern ${result.matchedPattern}` };
250
+ }
251
+ if (result.decision === "ask" && result.matchedPattern) {
252
+ return { action: "ask" };
253
+ }
254
+ }
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+
260
+ // Unknown tool — pass through
261
+ return null;
262
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared stdin reader for all hook scripts.
3
+ * Cross-platform (Windows/macOS/Linux) — no bash/jq dependency.
4
+ *
5
+ * Uses event-based flowing mode to avoid two platform bugs:
6
+ * - `for await (process.stdin)` hangs on macOS when piped via spawnSync
7
+ * - `readFileSync(0)` throws EOF/EISDIR on Windows, EAGAIN on Linux
8
+ */
9
+
10
+ export function readStdin() {
11
+ return new Promise((resolve, reject) => {
12
+ let data = "";
13
+ process.stdin.setEncoding("utf-8");
14
+ process.stdin.on("data", (chunk) => { data += chunk; });
15
+ process.stdin.on("end", () => resolve(data));
16
+ process.stdin.on("error", reject);
17
+ process.stdin.resume();
18
+ });
19
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Claude Code formatter — converts routing decisions into Claude Code hook output format.
3
+ *
4
+ * Claude Code expects:
5
+ * { hookSpecificOutput: { hookEventName, permissionDecision?, reason?, updatedInput?, additionalContext? } }
6
+ *
7
+ * Decision shape from routing.mjs:
8
+ * - { action: "deny", reason: string }
9
+ * - { action: "ask" }
10
+ * - { action: "modify", updatedInput: object }
11
+ * - { action: "context", additionalContext: string }
12
+ * - null (passthrough)
13
+ *
14
+ * @param {object | null} decision - Normalized decision from routePreToolUse
15
+ * @returns {object | null} Claude Code hook response, or null for passthrough
16
+ */
17
+ export function formatDecision(decision) {
18
+ if (!decision) return null;
19
+
20
+ switch (decision.action) {
21
+ case "deny":
22
+ return {
23
+ hookSpecificOutput: {
24
+ hookEventName: "PreToolUse",
25
+ permissionDecision: "deny",
26
+ reason: decision.reason ?? "Blocked by context-mode",
27
+ },
28
+ };
29
+
30
+ case "ask":
31
+ return {
32
+ hookSpecificOutput: {
33
+ hookEventName: "PreToolUse",
34
+ permissionDecision: "ask",
35
+ },
36
+ };
37
+
38
+ case "modify":
39
+ return {
40
+ hookSpecificOutput: {
41
+ hookEventName: "PreToolUse",
42
+ updatedInput: decision.updatedInput,
43
+ },
44
+ };
45
+
46
+ case "context":
47
+ return {
48
+ hookSpecificOutput: {
49
+ hookEventName: "PreToolUse",
50
+ additionalContext: decision.additionalContext ?? "",
51
+ },
52
+ };
53
+
54
+ default:
55
+ return null;
56
+ }
57
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Gemini CLI formatter — converts routing decisions into Gemini CLI hook output format.
3
+ *
4
+ * Gemini CLI expects:
5
+ * { hookSpecificOutput: { decision?, reason?, tool_input?, additionalContext? } }
6
+ *
7
+ * Key differences from Claude Code:
8
+ * - Uses "decision" instead of "permissionDecision"
9
+ * - Uses "tool_input" instead of "updatedInput"
10
+ * - No "ask" concept — Gemini CLI only supports deny/allow
11
+ *
12
+ * Decision shape from routing.mjs:
13
+ * - { action: "deny", reason: string }
14
+ * - { action: "ask" }
15
+ * - { action: "modify", updatedInput: object }
16
+ * - { action: "context", additionalContext: string }
17
+ * - null (passthrough)
18
+ *
19
+ * @param {object | null} decision - Normalized decision from routePreToolUse
20
+ * @returns {object | null} Gemini CLI hook response, or null for passthrough
21
+ */
22
+ export function formatDecision(decision) {
23
+ if (!decision) return null;
24
+
25
+ switch (decision.action) {
26
+ case "deny":
27
+ return {
28
+ hookSpecificOutput: {
29
+ decision: "deny",
30
+ reason: decision.reason ?? "Blocked by context-mode",
31
+ },
32
+ };
33
+
34
+ case "ask":
35
+ // Gemini CLI has no "ask" concept — return null (passthrough)
36
+ return null;
37
+
38
+ case "modify":
39
+ return {
40
+ hookSpecificOutput: {
41
+ tool_input: decision.updatedInput,
42
+ },
43
+ };
44
+
45
+ case "context":
46
+ return {
47
+ hookSpecificOutput: {
48
+ additionalContext: decision.additionalContext ?? "",
49
+ },
50
+ };
51
+
52
+ default:
53
+ return null;
54
+ }
55
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * VS Code Copilot formatter — converts routing decisions into VS Code Copilot hook output format.
3
+ *
4
+ * VS Code Copilot expects a flat structure for deny/ask, nested for modify/context:
5
+ * { permissionDecision: "deny", reason: "..." } — deny
6
+ * { permissionDecision: "ask" } — ask
7
+ * { hookSpecificOutput: { ... }, hookEventName: "PreToolUse" } — modify/context
8
+ *
9
+ * Key differences from Claude Code:
10
+ * - deny/ask are flat (not wrapped in hookSpecificOutput)
11
+ * - modify/context include hookEventName at top level alongside hookSpecificOutput
12
+ *
13
+ * Decision shape from routing.mjs:
14
+ * - { action: "deny", reason: string }
15
+ * - { action: "ask" }
16
+ * - { action: "modify", updatedInput: object }
17
+ * - { action: "context", additionalContext: string }
18
+ * - null (passthrough)
19
+ *
20
+ * @param {object | null} decision - Normalized decision from routePreToolUse
21
+ * @returns {object | null} VS Code Copilot hook response, or null for passthrough
22
+ */
23
+ export function formatDecision(decision) {
24
+ if (!decision) return null;
25
+
26
+ switch (decision.action) {
27
+ case "deny":
28
+ return {
29
+ permissionDecision: "deny",
30
+ reason: decision.reason ?? "Blocked by context-mode",
31
+ };
32
+
33
+ case "ask":
34
+ return {
35
+ permissionDecision: "ask",
36
+ };
37
+
38
+ case "modify":
39
+ return {
40
+ hookSpecificOutput: decision.updatedInput,
41
+ hookEventName: "PreToolUse",
42
+ };
43
+
44
+ case "context":
45
+ return {
46
+ hookSpecificOutput: {
47
+ additionalContext: decision.additionalContext ?? "",
48
+ },
49
+ hookEventName: "PreToolUse",
50
+ };
51
+
52
+ default:
53
+ return null;
54
+ }
55
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI AfterTool hook — session event capture.
4
+ *
5
+ * Captures session events from tool calls (13 categories) and stores
6
+ * them in the per-project SessionDB for later resume snapshot building.
7
+ *
8
+ * Must be fast (<20ms). No network, no LLM, just SQLite writes.
9
+ */
10
+
11
+ import { readStdin, getSessionId, getSessionDBPath, getProjectDir, GEMINI_OPTS } from "../session-helpers.mjs";
12
+ import { appendFileSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
18
+ const PKG_SESSION = join(HOOK_DIR, "..", "..", "build", "session");
19
+ const OPTS = GEMINI_OPTS;
20
+ const DEBUG_LOG = join(homedir(), ".gemini", "context-mode", "aftertool-debug.log");
21
+
22
+ try {
23
+ const raw = await readStdin();
24
+ const input = JSON.parse(raw);
25
+
26
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] CALL: ${input.tool_name}\n`);
27
+
28
+ const { extractEvents } = await import(join(PKG_SESSION, "extract.js"));
29
+ const { SessionDB } = await import(join(PKG_SESSION, "db.js"));
30
+
31
+ const dbPath = getSessionDBPath(OPTS);
32
+ const db = new SessionDB({ dbPath });
33
+ const sessionId = getSessionId(input, OPTS);
34
+
35
+ db.ensureSession(sessionId, getProjectDir(OPTS));
36
+
37
+ const events = extractEvents({
38
+ tool_name: input.tool_name,
39
+ tool_input: input.tool_input ?? {},
40
+ tool_response: typeof input.tool_response === "string"
41
+ ? input.tool_response
42
+ : JSON.stringify(input.tool_response ?? ""),
43
+ tool_output: input.tool_output,
44
+ });
45
+
46
+ for (const event of events) {
47
+ db.insertEvent(sessionId, event, "AfterTool");
48
+ }
49
+
50
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] OK: ${input.tool_name} → ${events.length} events\n`);
51
+ db.close();
52
+ } catch (err) {
53
+ try {
54
+ appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ERR: ${err?.message || err}\n`);
55
+ } catch { /* silent */ }
56
+ }
57
+
58
+ // AfterTool is non-blocking — no stdout output
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI BeforeTool hook for context-mode
4
+ * Thin wrapper — uses shared routing core, no self-heal, no Claude Code-specific logic.
5
+ */
6
+
7
+ import { dirname, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { readStdin } from "../core/stdin.mjs";
10
+ import { routePreToolUse, initSecurity } from "../core/routing.mjs";
11
+ import { formatDecision } from "../core/formatters.mjs";
12
+
13
+ const __hookDir = dirname(fileURLToPath(import.meta.url));
14
+ await initSecurity(resolve(__hookDir, "..", "..", "build"));
15
+
16
+ const raw = await readStdin();
17
+ const input = JSON.parse(raw);
18
+ const tool = input.tool_name ?? "";
19
+ const toolInput = input.tool_input ?? {};
20
+
21
+ const decision = routePreToolUse(tool, toolInput, process.env.GEMINI_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR);
22
+ const response = formatDecision("gemini-cli", decision);
23
+ if (response !== null) {
24
+ process.stdout.write(JSON.stringify(response) + "\n");
25
+ }