gemcode 0.3.31__tar.gz → 0.3.33__tar.gz

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 (121) hide show
  1. {gemcode-0.3.31/src/gemcode.egg-info → gemcode-0.3.33}/PKG-INFO +1 -1
  2. {gemcode-0.3.31 → gemcode-0.3.33}/pyproject.toml +1 -1
  3. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/agent.py +9 -2
  4. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/callbacks.py +22 -0
  5. gemcode-0.3.33/src/gemcode/permissions.py +206 -0
  6. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/repl_commands.py +28 -9
  7. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/repl_slash.py +125 -4
  8. gemcode-0.3.33/src/gemcode/review_agent.py +142 -0
  9. gemcode-0.3.33/src/gemcode/session_store.py +195 -0
  10. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/__init__.py +3 -1
  11. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/bash.py +146 -0
  12. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tui/input_handler.py +2 -0
  13. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tui/scrollback.py +13 -0
  14. {gemcode-0.3.31 → gemcode-0.3.33/src/gemcode.egg-info}/PKG-INFO +1 -1
  15. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode.egg-info/SOURCES.txt +2 -0
  16. gemcode-0.3.31/src/gemcode/permissions.py +0 -5
  17. {gemcode-0.3.31 → gemcode-0.3.33}/LICENSE +0 -0
  18. {gemcode-0.3.31 → gemcode-0.3.33}/MANIFEST.in +0 -0
  19. {gemcode-0.3.31 → gemcode-0.3.33}/README.md +0 -0
  20. {gemcode-0.3.31 → gemcode-0.3.33}/setup.cfg +0 -0
  21. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/__init__.py +0 -0
  22. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/__main__.py +0 -0
  23. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/audit.py +0 -0
  24. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/autocompact.py +0 -0
  25. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/capability_routing.py +0 -0
  26. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/cli.py +0 -0
  27. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/compaction.py +0 -0
  28. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/computer_use/__init__.py +0 -0
  29. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/computer_use/browser_computer.py +0 -0
  30. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/config.py +0 -0
  31. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/context_budget.py +0 -0
  32. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/context_warning.py +0 -0
  33. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/credentials.py +0 -0
  34. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/hitl_session.py +0 -0
  35. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/hooks.py +0 -0
  36. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/interactions.py +0 -0
  37. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/invoke.py +0 -0
  38. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/kairos_daemon.py +0 -0
  39. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/limits.py +0 -0
  40. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/live_audio_engine.py +0 -0
  41. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/logging_config.py +0 -0
  42. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/mcp_loader.py +0 -0
  43. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/memory/__init__.py +0 -0
  44. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/memory/embedding_memory_service.py +0 -0
  45. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/memory/file_memory_service.py +0 -0
  46. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/modality_tools.py +0 -0
  47. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/model_errors.py +0 -0
  48. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/model_routing.py +0 -0
  49. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/openapi_loader.py +0 -0
  50. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/paths.py +0 -0
  51. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/plugins/__init__.py +0 -0
  52. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/plugins/terminal_hooks_plugin.py +0 -0
  53. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/plugins/tool_recovery_plugin.py +0 -0
  54. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/pricing.py +0 -0
  55. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/prompt_suggestions.py +0 -0
  56. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/query/__init__.py +0 -0
  57. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/query/config.py +0 -0
  58. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/query/deps.py +0 -0
  59. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/query/engine.py +0 -0
  60. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/query/stop_hooks.py +0 -0
  61. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/query/token_budget.py +0 -0
  62. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/query/transitions.py +0 -0
  63. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/refine.py +0 -0
  64. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/session_runtime.py +0 -0
  65. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/slash_commands.py +0 -0
  66. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/thinking.py +0 -0
  67. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tool_prompt_manifest.py +0 -0
  68. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tool_registry.py +0 -0
  69. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/browser.py +0 -0
  70. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/edit.py +0 -0
  71. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/filesystem.py +0 -0
  72. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/notes.py +0 -0
  73. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/search.py +0 -0
  74. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/shell.py +0 -0
  75. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/shell_gate.py +0 -0
  76. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/subtask.py +0 -0
  77. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/think.py +0 -0
  78. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/todo.py +0 -0
  79. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools/web.py +0 -0
  80. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tools_inspector.py +0 -0
  81. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/trust.py +0 -0
  82. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tui/spinner.py +0 -0
  83. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tui/welcome_banner.py +0 -0
  84. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/tui/welcome_rich.py +0 -0
  85. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/version.py +0 -0
  86. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/vertex.py +0 -0
  87. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/web/__init__.py +0 -0
  88. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/web/claude_sse_adapter.py +0 -0
  89. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/web/terminal_repl.py +0 -0
  90. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode/workspace_hints.py +0 -0
  91. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode.egg-info/dependency_links.txt +0 -0
  92. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode.egg-info/entry_points.txt +0 -0
  93. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode.egg-info/requires.txt +0 -0
  94. {gemcode-0.3.31 → gemcode-0.3.33}/src/gemcode.egg-info/top_level.txt +0 -0
  95. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_agent_instruction.py +0 -0
  96. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_autocompact.py +0 -0
  97. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_capability_routing.py +0 -0
  98. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_claude_web_adapter_sse.py +0 -0
  99. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_cli_init.py +0 -0
  100. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_computer_use_permissions.py +0 -0
  101. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_context_budget.py +0 -0
  102. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_context_warning.py +0 -0
  103. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_credentials.py +0 -0
  104. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_interactive_permission_ask.py +0 -0
  105. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_kairos_scheduler.py +0 -0
  106. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_modality_tools.py +0 -0
  107. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_model_error_retry.py +0 -0
  108. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_model_errors.py +0 -0
  109. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_model_routing.py +0 -0
  110. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_paths.py +0 -0
  111. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_permissions.py +0 -0
  112. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_prompt_suggestions.py +0 -0
  113. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_repl_commands.py +0 -0
  114. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_repl_slash.py +0 -0
  115. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_slash_commands.py +0 -0
  116. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_thinking_config.py +0 -0
  117. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_token_budget.py +0 -0
  118. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_tool_context_circulation.py +0 -0
  119. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_tools.py +0 -0
  120. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_tools_inspector.py +0 -0
  121. {gemcode-0.3.31 → gemcode-0.3.33}/tests/test_workspace_hints.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemcode
3
- Version: 0.3.31
3
+ Version: 0.3.33
4
4
  Summary: Local-first coding agent on Google Gemini + ADK
5
5
  Author: GemCode Contributors
6
6
  License: Apache License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gemcode"
7
- version = "0.3.31"
7
+ version = "0.3.33"
8
8
  description = "Local-first coding agent on Google Gemini + ADK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -414,7 +414,7 @@ Never stop mid-task just because the first tool call succeeded. Keep going until
414
414
  | `bash("find . -name '*.py'")` | `glob_files("**/*.py")` |
415
415
  | `bash("cat file.py")` | `read_file("file.py")` |
416
416
  | `bash("grep -r pattern .")` | `grep_content("pattern", "**/*")` |
417
- | `bash("find . -type f \| head -50")` | `list_directory(".")` + `glob_files("**/*")` |
417
+ | `bash("find . -type f | head -50")` | `list_directory(".")` + `glob_files("**/*")` |
418
418
 
419
419
  Only reach for `bash` or `run_command` when you actually need to **execute** something: run tests, build, git ops, start a server, install packages. **NEVER** use bash to list or read files.
420
420
 
@@ -449,6 +449,13 @@ You have native deep thinking capability — use it actively:
449
449
  - For **dev servers**: `bash("npm run dev", background=True, cwd_subdir="frontend")`
450
450
  - For **subfolders**: `bash("cargo build --release", cwd_subdir="backend")`
451
451
 
452
+ - **`bash_stream`** — streaming version of bash: yields stdout line-by-line in real-time. Use for long-running commands where the user wants to see live progress:
453
+ - `bash_stream("pytest -v tests/")` — see each test result as it runs
454
+ - `bash_stream("npm run build")` — watch build progress in real-time
455
+ - `bash_stream("pip install -r requirements.txt")` — see install progress
456
+ - `bash_stream("tail -f logs/app.log", timeout_seconds=60)` — live log watching
457
+ - Prefer `bash_stream` over `bash` whenever the command takes > 5 seconds and progress visibility matters
458
+
452
459
  - **`run_command`** — simple single-executable calls without shell features:
453
460
  - `run_command("npm", args=["install", "--legacy-peer-deps"])` — clean npm install
454
461
  - `run_command("python3", args=["-m", "pytest", "--version"])` — version check
@@ -556,7 +563,7 @@ When `artifacts ON` (see Runtime facts above):
556
563
  - Screenshots from computer_use: save as "screenshot.png" artifact
557
564
  - Generated reports/PDFs: save as "report.pdf" artifact
558
565
  - Large JSON data: save as "data.json" artifact
559
- - **Reference** artifacts in instructions via `{artifact.filename?}` template syntax
566
+ - **Reference** artifacts in instructions via artifact.filename template syntax
560
567
  - Artifacts are keyed by filename; `user:` prefix = cross-session persistence
561
568
 
562
569
  ## Code Executor (sandboxed Python)
@@ -157,6 +157,24 @@ def make_before_tool_callback(cfg: GemCodeConfig):
157
157
  except Exception:
158
158
  pass
159
159
 
160
+ # ── Permission rules (.gemcode/settings.json allow/deny) ──────────────
161
+ # Evaluated before the normal permission flow so explicit allow/deny rules
162
+ # override cfg.permission_mode and interactive prompts.
163
+ try:
164
+ from gemcode.permissions import check_rules
165
+ rule_result = check_rules(name, args or {}, cfg.project_root)
166
+ if rule_result == "deny":
167
+ return {
168
+ "error": f"Tool call blocked by .gemcode/settings.json deny rule for '{name}'.",
169
+ "error_kind": _ERROR_KIND_PERMISSION_DENIED,
170
+ }
171
+ if rule_result == "allow":
172
+ # Explicit allow — skip the normal permission prompt entirely
173
+ # but still run post-tool hook (logging/audit).
174
+ pass # fall through to tool execution
175
+ except Exception:
176
+ rule_result = None
177
+
160
178
  streak = 0
161
179
  if tool_context is not None:
162
180
  try:
@@ -181,6 +199,10 @@ def make_before_tool_callback(cfg: GemCodeConfig):
181
199
  "error_kind": _ERROR_KIND_CIRCUIT_BREAKER,
182
200
  }
183
201
 
202
+ # If permission rules explicitly allowed this tool call, skip the gate entirely.
203
+ if rule_result == "allow" and (name in MUTATING_TOOLS or is_computer_tool):
204
+ return None # allow without prompting
205
+
184
206
  if name in MUTATING_TOOLS or is_computer_tool:
185
207
  if cfg.permission_mode == "strict":
186
208
  if is_computer_tool:
@@ -0,0 +1,206 @@
1
+ """
2
+ Permission rules engine — inspired by Claude Code's allow/deny pattern system.
3
+
4
+ Users define rules in `.gemcode/settings.json` (project) or `~/.gemcode/settings.json` (global).
5
+ Rules are evaluated for every tool call: deny first, then allow, then default.
6
+
7
+ Schema:
8
+ {
9
+ "permissions": {
10
+ "allow": [
11
+ "bash(git *)",
12
+ "bash(npm run *)",
13
+ "bash(pytest *)",
14
+ "read_file(*)",
15
+ "write_file(src/**)"
16
+ ],
17
+ "deny": [
18
+ "bash(rm -rf *)",
19
+ "bash(curl *)",
20
+ "bash(wget *)",
21
+ "read_file(.env)",
22
+ "read_file(.env.*)",
23
+ "write_file(secrets/**)"
24
+ ]
25
+ }
26
+ }
27
+
28
+ Pattern syntax (same as Claude Code's permission rules):
29
+ - "bash" — matches ALL bash calls
30
+ - "bash(*)" — same as above
31
+ - "bash(git *)" — bash calls whose command starts with "git "
32
+ - "write_file(src/**)" — write_file calls where path matches src/**
33
+ - "read_file(.env)" — exact path match for .env
34
+ - "*" — matches all tool calls
35
+
36
+ Evaluation order: deny → allow → default (ask/yes_to_all/strict from cfg)
37
+ First matching rule wins.
38
+
39
+ Files (merged in order — later rules take precedence):
40
+ 1. ~/.gemcode/settings.json (user-global)
41
+ 2. .gemcode/settings.json (project-specific, can override global)
42
+
43
+ Reloaded on every tool call (file is stat-cached to avoid repeated disk reads).
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import fnmatch
49
+ import json
50
+ import os
51
+ import time
52
+ from pathlib import Path
53
+ from typing import Any
54
+
55
+ # ── Cache so we don't re-read files every millisecond ────────────────────────
56
+ _file_cache: dict[str, tuple[float, float, list]] = {} # path → (mtime, stat_time, rules)
57
+ _CACHE_TTL = 2.0 # seconds
58
+
59
+
60
+ def _read_settings_rules(path: Path) -> list[dict]:
61
+ """Read and cache permission rules from a settings file."""
62
+ path_str = str(path)
63
+ now = time.monotonic()
64
+ if path_str in _file_cache:
65
+ cached_mtime, cached_time, cached_rules = _file_cache[path_str]
66
+ if now - cached_time < _CACHE_TTL:
67
+ return cached_rules
68
+
69
+ if not path.is_file():
70
+ _file_cache[path_str] = (0.0, now, [])
71
+ return []
72
+
73
+ try:
74
+ mtime = path.stat().st_mtime
75
+ if path_str in _file_cache and _file_cache[path_str][0] == mtime:
76
+ # File hasn't changed — just refresh the TTL
77
+ old = _file_cache[path_str]
78
+ _file_cache[path_str] = (old[0], now, old[2])
79
+ return old[2]
80
+
81
+ data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
82
+ perms = data.get("permissions") or {}
83
+ rules: list[dict] = []
84
+ for pattern in perms.get("deny") or []:
85
+ rules.append({"action": "deny", "pattern": str(pattern)})
86
+ for pattern in perms.get("allow") or []:
87
+ rules.append({"action": "allow", "pattern": str(pattern)})
88
+ _file_cache[path_str] = (mtime, now, rules)
89
+ return rules
90
+ except Exception:
91
+ _file_cache[path_str] = (0.0, now, [])
92
+ return []
93
+
94
+
95
+ def load_rules(project_root: Path) -> list[dict]:
96
+ """
97
+ Load merged rules from user-global and project settings.
98
+ Order: global first (lower priority), project second (higher priority / overrides).
99
+ """
100
+ global_rules = _read_settings_rules(Path.home() / ".gemcode" / "settings.json")
101
+ project_rules = _read_settings_rules(project_root / ".gemcode" / "settings.json")
102
+ # Deny rules always evaluated first regardless of file order, so combine and re-sort
103
+ all_rules = global_rules + project_rules
104
+ # Stable sort: deny rules before allow rules (deny has priority)
105
+ deny_rules = [r for r in all_rules if r["action"] == "deny"]
106
+ allow_rules = [r for r in all_rules if r["action"] == "allow"]
107
+ return deny_rules + allow_rules
108
+
109
+
110
+ def _parse_pattern(pattern: str) -> tuple[str, str | None]:
111
+ """
112
+ Parse a rule pattern into (tool_glob, arg_glob | None).
113
+
114
+ Examples:
115
+ "bash" → ("bash", None)
116
+ "bash(*)" → ("bash", "*")
117
+ "bash(git *)" → ("bash", "git *")
118
+ "write_file(src/*)→ ("write_file", "src/*")
119
+ "*" → ("*", None)
120
+ """
121
+ pattern = pattern.strip()
122
+ paren_open = pattern.find("(")
123
+ if paren_open == -1:
124
+ return (pattern, None)
125
+ tool_glob = pattern[:paren_open].strip()
126
+ arg_part = pattern[paren_open + 1:]
127
+ if arg_part.endswith(")"):
128
+ arg_part = arg_part[:-1]
129
+ return (tool_glob, arg_part.strip() or None)
130
+
131
+
132
+ def _get_primary_arg(tool_name: str, args: dict[str, Any]) -> str:
133
+ """
134
+ Extract the primary argument to match against the pattern's arg_glob.
135
+ For bash: the command string.
136
+ For file tools: the path argument.
137
+ For everything else: concatenate all string args.
138
+ """
139
+ if tool_name in ("bash", "run_command"):
140
+ return (
141
+ args.get("command") or
142
+ args.get("cmd") or
143
+ args.get("args", [""])[0] if isinstance(args.get("args"), list) else args.get("args", "")
144
+ ) or ""
145
+ # File tools — use first path-like arg
146
+ for key in ("path", "file_path", "file", "filename", "dest", "src"):
147
+ val = args.get(key)
148
+ if val and isinstance(val, str):
149
+ return val
150
+ # Fallback: join all string values
151
+ return " ".join(str(v) for v in args.values() if isinstance(v, str))
152
+
153
+
154
+ def check_rules(
155
+ tool_name: str,
156
+ args: dict[str, Any],
157
+ project_root: Path,
158
+ ) -> str | None:
159
+ """
160
+ Evaluate permission rules for a tool call.
161
+
162
+ Returns:
163
+ "allow" — explicit allow rule matched → skip normal permission prompts
164
+ "deny" — explicit deny rule matched → block the tool call
165
+ None — no rule matched → use default behavior (cfg.permission_mode)
166
+ """
167
+ rules = load_rules(project_root)
168
+ if not rules:
169
+ return None
170
+
171
+ primary_arg = _get_primary_arg(tool_name, args)
172
+
173
+ for rule in rules:
174
+ tool_glob, arg_glob = _parse_pattern(rule["pattern"])
175
+
176
+ # Match tool name
177
+ if tool_glob != "*" and not fnmatch.fnmatch(tool_name, tool_glob):
178
+ continue
179
+
180
+ # Match argument (if pattern specifies one)
181
+ if arg_glob is not None and arg_glob != "*":
182
+ if not fnmatch.fnmatch(primary_arg, arg_glob):
183
+ continue
184
+
185
+ return rule["action"] # "allow" or "deny"
186
+
187
+ return None # no rule matched
188
+
189
+
190
+ def describe_rules(project_root: Path) -> list[str]:
191
+ """Human-readable rule listing for /permissions output."""
192
+ rules = load_rules(project_root)
193
+ if not rules:
194
+ return [" (no rules — using default permission_mode)"]
195
+ lines: list[str] = []
196
+ deny_rules = [r for r in rules if r["action"] == "deny"]
197
+ allow_rules = [r for r in rules if r["action"] == "allow"]
198
+ if deny_rules:
199
+ lines.append(" deny:")
200
+ for r in deny_rules:
201
+ lines.append(f" ✗ {r['pattern']}")
202
+ if allow_rules:
203
+ lines.append(" allow:")
204
+ for r in allow_rules:
205
+ lines.append(f" ✓ {r['pattern']}")
206
+ return lines
@@ -70,6 +70,10 @@ def format_model_lines(cfg: GemCodeConfig) -> list[str]:
70
70
 
71
71
 
72
72
  def format_permissions_lines(cfg: GemCodeConfig) -> list[str]:
73
+ from gemcode.permissions import describe_rules
74
+ rules_lines = describe_rules(cfg.project_root)
75
+ settings_file = cfg.project_root / ".gemcode" / "settings.json"
76
+ global_settings = os.path.expanduser("~/.gemcode/settings.json")
73
77
  ac = cfg.allow_commands
74
78
  if ac is None:
75
79
  preview = "(default allowlist)"
@@ -78,14 +82,23 @@ def format_permissions_lines(cfg: GemCodeConfig) -> list[str]:
78
82
  preview = ", ".join(names[:16])
79
83
  if len(names) > 16:
80
84
  preview += f", … (+{len(names) - 16} more)"
81
- return [
85
+ lines = [
82
86
  "Permissions:",
83
- f" permission_mode: {cfg.permission_mode}",
84
- f" yes_to_all: {cfg.yes_to_all}",
85
- f" interactive_permission_ask: {getattr(cfg, 'interactive_permission_ask', False)}",
86
- f" interactive_hitl_sticky_session: {getattr(cfg, 'interactive_hitl_sticky_session', True)}",
87
- f" allow_commands: {preview}",
87
+ f" permission_mode : {cfg.permission_mode}",
88
+ f" yes_to_all : {cfg.yes_to_all}",
89
+ f" interactive_permission: {getattr(cfg, 'interactive_permission_ask', False)}",
90
+ f" hitl_sticky_session : {getattr(cfg, 'interactive_hitl_sticky_session', True)}",
91
+ f" allow_commands : {preview}",
92
+ "",
93
+ " Settings-based rules (deny first, then allow):",
94
+ f" Project: {settings_file} ({'exists' if settings_file.exists() else 'not found'})",
95
+ f" Global : {global_settings} ({'exists' if os.path.exists(global_settings) else 'not found'})",
88
96
  ]
97
+ lines.extend(rules_lines)
98
+ lines.append("")
99
+ lines.append(" To add rules, create .gemcode/settings.json:")
100
+ lines.append(' { "permissions": { "allow": ["bash(git *)"], "deny": ["bash(rm -rf *)"] } }')
101
+ return lines
89
102
 
90
103
 
91
104
  def format_memory_lines(cfg: GemCodeConfig) -> list[str]:
@@ -201,10 +214,16 @@ def slash_help_lines() -> list[str]:
201
214
  " /help Show this help",
202
215
  " /status Full status: model, capabilities, thinking, limits",
203
216
  " /config All active config fields (model, caps, context, thinking)",
204
- " /session Print current session id",
205
- " /session new Start a new session id (history reset)",
217
+ " /session Print current session id and name",
218
+ " /session list List all sessions (most recent first)",
219
+ " /session name <n> Name the current session",
220
+ " /session resume <n> Resume session by name, ID prefix, or full UUID",
221
+ " /session new Start a fresh session (history reset)",
206
222
  " /clear Alias for /session new",
207
- " /compact Force autocompact now (summarize history)",
223
+ " /compact Force context compaction now (summarize history)",
224
+ " /compact <focus> Compact with custom focus, e.g. /compact test output",
225
+ " /review Parallel code review: security + style + correctness",
226
+ " /review <path> Review a specific file or directory",
208
227
  " /context Show context pressure + last prompt tokens",
209
228
  " /audit [N] Tail of .gemcode/audit.log (default 40 lines)",
210
229
  " /tools List tool inventory for this config",
@@ -155,6 +155,39 @@ async def process_repl_slash(
155
155
  return ReplSlashResult(skip_model_turn=True)
156
156
 
157
157
  # ── /init ─────────────────────────────────────────────────────────────────
158
+ # ── /review ───────────────────────────────────────────────────────────────
159
+ if name == "review":
160
+ scope = (sc.args or "").strip()
161
+ # Build a prompt that includes the diff/files as context for the pipeline.
162
+ # We dispatch this as a model_prompt so the main agent runs the review pipeline.
163
+ if scope:
164
+ review_prompt = (
165
+ f"Run a parallel code review on: {scope}\n\n"
166
+ "Use the review_code tool if available, or:\n"
167
+ "1. If it's a file/directory, read the relevant files with read_file/list_directory\n"
168
+ "2. Run `bash('git diff HEAD -- " + scope + "')` to see recent changes\n"
169
+ "3. Produce a parallel code review using your SecurityReviewer, StyleReviewer, "
170
+ "and CorrectnessReviewer sub-agents (via run_subtask with focus='security'/'style'/'correctness'), "
171
+ "then synthesize the findings into a structured report with: Critical Issues, Suggestions, Verdict."
172
+ )
173
+ else:
174
+ review_prompt = (
175
+ "Run a parallel code review on the current changes.\n\n"
176
+ "Steps:\n"
177
+ "1. Run `bash('git diff HEAD')` to see unstaged changes, and "
178
+ "`bash('git diff --cached')` for staged changes\n"
179
+ "2. If no git diff, run `bash('git diff HEAD~1')` for the last commit\n"
180
+ "3. Run THREE parallel sub-reviews using run_subtask:\n"
181
+ " - Security review (auth, injections, secrets, validation)\n"
182
+ " - Style review (readability, naming, DRY, docs)\n"
183
+ " - Correctness review (logic, error handling, edge cases, tests)\n"
184
+ "4. Synthesize into a final report: Critical Issues / Suggestions / Verdict\n\n"
185
+ "Run all three sub-reviews simultaneously using run_subtask for maximum speed."
186
+ )
187
+ out("Running parallel code review (security + style + correctness)…")
188
+ out()
189
+ return ReplSlashResult(model_prompt=review_prompt)
190
+
158
191
  # ── /notes ────────────────────────────────────────────────────────────────
159
192
  if name == "notes":
160
193
  sub = (sc.args or "").strip().lower()
@@ -366,12 +399,76 @@ async def process_repl_slash(
366
399
  return ReplSlashResult(skip_model_turn=True)
367
400
 
368
401
  if name in ("session", "clear"):
369
- if name == "clear" or sc.args.strip().lower() in ("new", "reset"):
402
+ args_lower = (sc.args or "").strip().lower()
403
+ args_raw = (sc.args or "").strip()
404
+
405
+ # /clear or /session new — start fresh session
406
+ if name == "clear" or args_lower in ("new", "reset"):
370
407
  new_id = str(uuid.uuid4())
371
408
  out(f"new session_id: {new_id}")
372
409
  out()
373
410
  return ReplSlashResult(skip_model_turn=True, new_session_id=new_id)
411
+
412
+ # /session list — show recent sessions
413
+ if args_lower in ("list", "ls", "history"):
414
+ from gemcode.session_store import list_sessions, format_session_list
415
+ sessions = list_sessions(cfg.project_root)
416
+ out("Recent sessions (most recent first):")
417
+ out("─" * 55)
418
+ for line in format_session_list(sessions):
419
+ out(line)
420
+ out()
421
+ out(" /session resume <id|name> — resume a session")
422
+ out(" /session name <name> — name current session")
423
+ out()
424
+ return ReplSlashResult(skip_model_turn=True)
425
+
426
+ # /session name <name> — give a name to the current session
427
+ if args_lower.startswith("name "):
428
+ new_name = args_raw[5:].strip()
429
+ if not new_name:
430
+ out("Usage: /session name <name>")
431
+ return ReplSlashResult(skip_model_turn=True)
432
+ from gemcode.session_store import name_session
433
+ name_session(cfg.project_root, session_id, new_name)
434
+ out(f"Session named: '{new_name}'")
435
+ out(f"Session id : {session_id[:8]}…")
436
+ out()
437
+ return ReplSlashResult(skip_model_turn=True)
438
+
439
+ # /session resume <id|name> — switch to a different session
440
+ if args_lower.startswith("resume ") or args_lower.startswith("r "):
441
+ query = args_raw.split(None, 1)[1].strip() if " " in args_raw else ""
442
+ if not query:
443
+ out("Usage: /session resume <session-id|name|prefix>")
444
+ return ReplSlashResult(skip_model_turn=True)
445
+ from gemcode.session_store import find_session
446
+ found = find_session(cfg.project_root, query)
447
+ if found is None:
448
+ out(f"No session found matching '{query}'.")
449
+ out("Use /session list to see available sessions.")
450
+ out()
451
+ return ReplSlashResult(skip_model_turn=True)
452
+ if found == session_id:
453
+ out(f"Already in session {found[:8]}.")
454
+ out()
455
+ return ReplSlashResult(skip_model_turn=True)
456
+ out(f"Resuming session {found[:8]}…")
457
+ out()
458
+ return ReplSlashResult(skip_model_turn=True, new_session_id=found)
459
+
460
+ # Default: show current session info
461
+ from gemcode.session_store import get_session_name, touch_session
462
+ touch_session(cfg.project_root, session_id)
463
+ session_name = get_session_name(cfg.project_root, session_id)
374
464
  out(f"session_id: {session_id}")
465
+ if session_name:
466
+ out(f"name : {session_name}")
467
+ out()
468
+ out(" /session list — list all sessions")
469
+ out(" /session name <name> — name this session")
470
+ out(" /session resume <id|name> — resume another session")
471
+ out(" /session new — start a fresh session")
375
472
  out()
376
473
  return ReplSlashResult(skip_model_turn=True)
377
474
 
@@ -418,12 +515,36 @@ async def process_repl_slash(
418
515
  return ReplSlashResult(skip_model_turn=True)
419
516
 
420
517
  if name == "compact":
518
+ focus = (sc.args or "").strip()
421
519
  os.environ["GEMCODE_AUTOCOMPACT_FORCE"] = "1"
520
+ if focus:
521
+ compact_prompt = (
522
+ f"Compact the conversation history now. Focus on preserving: {focus}\n\n"
523
+ "Create a concise summary that retains:\n"
524
+ "- All decisions, conclusions, and code changes made\n"
525
+ f"- Special attention to: {focus}\n"
526
+ "- Any open tasks or blockers\n"
527
+ "- Key file paths and architecture insights\n\n"
528
+ "After summarizing, reply with a brief confirmation of what was preserved."
529
+ )
530
+ else:
531
+ compact_prompt = (
532
+ "Compact the conversation history now.\n\n"
533
+ "Create a concise summary preserving:\n"
534
+ "- All decisions, conclusions, and code changes made\n"
535
+ "- Any open tasks or next steps\n"
536
+ "- Key file paths, commands, and architecture insights\n"
537
+ "- Error messages and their resolutions\n\n"
538
+ "After summarizing, reply with a brief confirmation. "
539
+ "Tip: use `/compact <focus>` to specify what to prioritize, e.g. `/compact test output and error messages`"
540
+ )
541
+ out("Compacting context history…")
542
+ if focus:
543
+ out(f"Focus: {focus}")
544
+ out()
422
545
  return ReplSlashResult(
423
546
  skip_model_turn=False,
424
- model_prompt=(
425
- "Compact the conversation history now. Reply with: Compacted."
426
- ),
547
+ model_prompt=compact_prompt,
427
548
  )
428
549
 
429
550
  if name in ("exit", "quit"):
@@ -0,0 +1,142 @@
1
+ """
2
+ Parallel code-review pipeline using ADK SequentialAgent + ParallelAgent.
3
+
4
+ Inspired by the ADK community financial-advisor pattern:
5
+ ParallelAgent (3 specialist reviewers run simultaneously)
6
+ → SecurityReviewer (writes to state["security_findings"])
7
+ → StyleReviewer (writes to state["style_findings"])
8
+ → CorrectnessReviewer(writes to state["correctness_findings"])
9
+ → SynthesisAgent (reads all three state keys and produces final report)
10
+
11
+ The pipeline is wrapped in a SequentialAgent:
12
+ ParallelReviewers → Synthesizer
13
+
14
+ Called from the /review slash command with an optional scope argument:
15
+ /review — review recent git diff (staged + unstaged changes)
16
+ /review src/auth/ — review a specific directory
17
+ /review file.py — review a specific file
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ _GEMINI_FLASH = "gemini-2.5-flash"
25
+ _GEMINI_PRO = "gemini-2.5-pro"
26
+
27
+
28
+ def build_review_pipeline(model: str | None = None) -> Any:
29
+ """
30
+ Build and return a SequentialAgent [ParallelReviewers → Synthesizer].
31
+
32
+ The pipeline is stateless — pass the diff/files as the initial prompt.
33
+ Uses flash for the three parallel reviewers (fast + cheap) and the
34
+ provided model (or flash) for the final synthesis.
35
+ """
36
+ try:
37
+ from google.adk.agents import LlmAgent, SequentialAgent, ParallelAgent
38
+ except ImportError as e:
39
+ raise ImportError("google-adk is required for /review") from e
40
+
41
+ synthesizer_model = model or _GEMINI_FLASH
42
+
43
+ security_reviewer = LlmAgent(
44
+ name="SecurityReviewer",
45
+ model=_GEMINI_FLASH,
46
+ include_contents="none", # Stateless — only sees what's injected via state
47
+ instruction="""You are a security-focused code reviewer.
48
+ Review the code changes provided in the message. Focus exclusively on:
49
+ - Authentication/authorization bypasses
50
+ - Injection vulnerabilities (SQL, command, path traversal)
51
+ - Secrets or credentials in code
52
+ - Insecure deserialization or parsing
53
+ - Missing input validation or sanitization
54
+ - Race conditions affecting security
55
+ - Dangerous use of eval/exec/subprocess
56
+
57
+ For each issue: SEVERITY (Critical/High/Medium/Low), FILE+LINE if visible, description, fix suggestion.
58
+ If no security issues found, say "No security issues found."
59
+ Be concise — max 300 words. Output ONLY your security findings.""",
60
+ output_key="security_findings",
61
+ )
62
+
63
+ style_reviewer = LlmAgent(
64
+ name="StyleReviewer",
65
+ model=_GEMINI_FLASH,
66
+ include_contents="none",
67
+ instruction="""You are a code style and readability reviewer.
68
+ Review the code changes provided in the message. Focus exclusively on:
69
+ - Code readability and clarity
70
+ - Naming conventions (variables, functions, classes)
71
+ - DRY violations and unnecessary duplication
72
+ - Function/method length and complexity
73
+ - Missing or poor documentation/comments
74
+ - Inconsistent formatting or style
75
+ - Dead code or unused imports
76
+
77
+ For each issue: SEVERITY (High/Medium/Low), FILE+LINE if visible, description, suggestion.
78
+ If no style issues found, say "No style issues found."
79
+ Be concise — max 300 words. Output ONLY your style findings.""",
80
+ output_key="style_findings",
81
+ )
82
+
83
+ correctness_reviewer = LlmAgent(
84
+ name="CorrectnessReviewer",
85
+ model=_GEMINI_FLASH,
86
+ include_contents="none",
87
+ instruction="""You are a correctness-focused code reviewer.
88
+ Review the code changes provided in the message. Focus exclusively on:
89
+ - Logic errors and off-by-one bugs
90
+ - Null/undefined pointer dereferences
91
+ - Incorrect error handling or exception swallowing
92
+ - Type mismatches or coercion issues
93
+ - Missing edge case handling
94
+ - Resource leaks (file handles, connections, memory)
95
+ - Incorrect algorithm implementation
96
+ - Broken tests or missing test coverage
97
+
98
+ For each issue: SEVERITY (Critical/High/Medium/Low), FILE+LINE if visible, description, fix suggestion.
99
+ If no correctness issues found, say "No correctness issues found."
100
+ Be concise — max 300 words. Output ONLY your correctness findings.""",
101
+ output_key="correctness_findings",
102
+ )
103
+
104
+ synthesizer = LlmAgent(
105
+ name="ReviewSynthesizer",
106
+ model=synthesizer_model,
107
+ include_contents="none",
108
+ instruction="""You are a senior engineering lead synthesizing a code review.
109
+
110
+ You have three specialist review reports in state:
111
+ - Security findings: {security_findings}
112
+ - Style findings: {style_findings}
113
+ - Correctness: {correctness_findings}
114
+
115
+ Produce a clear, actionable code review report:
116
+
117
+ ## Code Review Summary
118
+
119
+ ### Critical / Must Fix
120
+ [List only Critical and High severity issues from all reviewers, grouped by file if possible]
121
+
122
+ ### Suggestions
123
+ [List Medium/Low severity issues worth addressing, grouped by category]
124
+
125
+ ### Verdict
126
+ [One of: APPROVE / APPROVE WITH MINOR CHANGES / REQUEST CHANGES — with 1-2 sentence justification]
127
+
128
+ Keep the report under 500 words. Be direct and developer-friendly.""",
129
+ )
130
+
131
+ parallel_reviewers = ParallelAgent(
132
+ name="ParallelReviewers",
133
+ sub_agents=[security_reviewer, style_reviewer, correctness_reviewer],
134
+ )
135
+
136
+ pipeline = SequentialAgent(
137
+ name="CodeReviewPipeline",
138
+ description="Parallel code review: security + style + correctness, then synthesis.",
139
+ sub_agents=[parallel_reviewers, synthesizer],
140
+ )
141
+
142
+ return pipeline