overcode 0.1.0__py3-none-any.whl
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.
- overcode/__init__.py +5 -0
- overcode/cli.py +812 -0
- overcode/config.py +72 -0
- overcode/daemon.py +1184 -0
- overcode/daemon_claude_skill.md +180 -0
- overcode/daemon_state.py +113 -0
- overcode/data_export.py +257 -0
- overcode/dependency_check.py +227 -0
- overcode/exceptions.py +219 -0
- overcode/history_reader.py +448 -0
- overcode/implementations.py +214 -0
- overcode/interfaces.py +49 -0
- overcode/launcher.py +434 -0
- overcode/logging_config.py +193 -0
- overcode/mocks.py +152 -0
- overcode/monitor_daemon.py +808 -0
- overcode/monitor_daemon_state.py +358 -0
- overcode/pid_utils.py +225 -0
- overcode/presence_logger.py +454 -0
- overcode/protocols.py +143 -0
- overcode/session_manager.py +606 -0
- overcode/settings.py +412 -0
- overcode/standing_instructions.py +276 -0
- overcode/status_constants.py +190 -0
- overcode/status_detector.py +339 -0
- overcode/status_history.py +164 -0
- overcode/status_patterns.py +264 -0
- overcode/summarizer_client.py +136 -0
- overcode/summarizer_component.py +312 -0
- overcode/supervisor_daemon.py +1000 -0
- overcode/supervisor_layout.sh +50 -0
- overcode/tmux_manager.py +228 -0
- overcode/tui.py +2549 -0
- overcode/tui_helpers.py +495 -0
- overcode/web_api.py +279 -0
- overcode/web_server.py +138 -0
- overcode/web_templates.py +563 -0
- overcode-0.1.0.dist-info/METADATA +87 -0
- overcode-0.1.0.dist-info/RECORD +43 -0
- overcode-0.1.0.dist-info/WHEEL +5 -0
- overcode-0.1.0.dist-info/entry_points.txt +2 -0
- overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
- overcode-0.1.0.dist-info/top_level.txt +1 -0
overcode/settings.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized configuration and settings for Overcode.
|
|
3
|
+
|
|
4
|
+
This module consolidates all configuration constants, paths, and settings
|
|
5
|
+
that were previously scattered across multiple modules.
|
|
6
|
+
|
|
7
|
+
Configuration hierarchy:
|
|
8
|
+
1. Environment variables (highest priority)
|
|
9
|
+
2. Config file (~/.overcode/config.yaml)
|
|
10
|
+
3. Default values (lowest priority)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
# =============================================================================
|
|
16
|
+
# Version - increment when daemon code changes significantly
|
|
17
|
+
# =============================================================================
|
|
18
|
+
|
|
19
|
+
DAEMON_VERSION = 2 # Increment when daemon behavior changes
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# Base Paths
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
def get_overcode_dir() -> Path:
|
|
32
|
+
"""Get the overcode data directory.
|
|
33
|
+
|
|
34
|
+
Can be overridden with OVERCODE_DIR environment variable.
|
|
35
|
+
"""
|
|
36
|
+
env_dir = os.environ.get("OVERCODE_DIR")
|
|
37
|
+
if env_dir:
|
|
38
|
+
return Path(env_dir)
|
|
39
|
+
return Path.home() / ".overcode"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_state_dir() -> Path:
|
|
43
|
+
"""Get the state directory for session files.
|
|
44
|
+
|
|
45
|
+
Can be overridden with OVERCODE_STATE_DIR environment variable.
|
|
46
|
+
"""
|
|
47
|
+
env_dir = os.environ.get("OVERCODE_STATE_DIR")
|
|
48
|
+
if env_dir:
|
|
49
|
+
return Path(env_dir)
|
|
50
|
+
return get_overcode_dir() / "sessions"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_log_dir() -> Path:
|
|
54
|
+
"""Get the log directory."""
|
|
55
|
+
return get_overcode_dir() / "logs"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# =============================================================================
|
|
59
|
+
# File Paths
|
|
60
|
+
# =============================================================================
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class OvercodePaths:
|
|
64
|
+
"""All file paths used by Overcode."""
|
|
65
|
+
|
|
66
|
+
# Base directory
|
|
67
|
+
base_dir: Path = field(default_factory=get_overcode_dir)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def config_file(self) -> Path:
|
|
71
|
+
"""Configuration file path."""
|
|
72
|
+
return self.base_dir / "config.yaml"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def state_dir(self) -> Path:
|
|
76
|
+
"""Session state directory."""
|
|
77
|
+
return get_state_dir()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def sessions_file(self) -> Path:
|
|
81
|
+
"""Sessions state file."""
|
|
82
|
+
return self.state_dir / "sessions.json"
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def log_dir(self) -> Path:
|
|
86
|
+
"""Log directory."""
|
|
87
|
+
return get_log_dir()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def daemon_log(self) -> Path:
|
|
91
|
+
"""Daemon log file."""
|
|
92
|
+
return self.base_dir / "daemon.log"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def daemon_pid(self) -> Path:
|
|
96
|
+
"""Daemon PID file."""
|
|
97
|
+
return self.base_dir / "daemon.pid"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def daemon_state(self) -> Path:
|
|
101
|
+
"""Daemon state file (legacy - supervisor daemon)."""
|
|
102
|
+
return self.base_dir / "daemon_state.json"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def monitor_daemon_state(self) -> Path:
|
|
106
|
+
"""Monitor daemon state file (new - single source of truth)."""
|
|
107
|
+
return self.base_dir / "monitor_daemon_state.json"
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def monitor_daemon_pid(self) -> Path:
|
|
111
|
+
"""Monitor daemon PID file."""
|
|
112
|
+
return self.base_dir / "monitor_daemon.pid"
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def supervisor_daemon_pid(self) -> Path:
|
|
116
|
+
"""Supervisor daemon PID file."""
|
|
117
|
+
return self.base_dir / "supervisor_daemon.pid"
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def presence_pid(self) -> Path:
|
|
121
|
+
"""Presence logger PID file."""
|
|
122
|
+
return self.base_dir / "presence.pid"
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def presence_log(self) -> Path:
|
|
126
|
+
"""Presence log file."""
|
|
127
|
+
return self.base_dir / "presence_log.csv"
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def activity_signal(self) -> Path:
|
|
131
|
+
"""Activity signal file for daemon."""
|
|
132
|
+
return self.base_dir / "activity_signal"
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def agent_history(self) -> Path:
|
|
136
|
+
"""Agent status history CSV."""
|
|
137
|
+
return self.base_dir / "agent_status_history.csv"
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def supervisor_log(self) -> Path:
|
|
141
|
+
"""Supervisor log file."""
|
|
142
|
+
return self.base_dir / "supervisor.log"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Global paths instance
|
|
146
|
+
PATHS = OvercodePaths()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# =============================================================================
|
|
150
|
+
# Daemon Settings
|
|
151
|
+
# =============================================================================
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class DaemonSettings:
|
|
155
|
+
"""Settings for the daemon."""
|
|
156
|
+
|
|
157
|
+
# Polling intervals (seconds)
|
|
158
|
+
interval_fast: int = 10 # When active or agents working
|
|
159
|
+
interval_slow: int = 300 # When all agents need user input (5 min)
|
|
160
|
+
interval_idle: int = 3600 # When no agents at all (1 hour)
|
|
161
|
+
|
|
162
|
+
# Daemon Claude settings
|
|
163
|
+
daemon_claude_timeout: int = 300 # Max wait for daemon claude (5 min)
|
|
164
|
+
daemon_claude_poll: int = 5 # Poll interval for daemon claude
|
|
165
|
+
|
|
166
|
+
# Default tmux session name
|
|
167
|
+
default_tmux_session: str = "agents"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Global daemon settings
|
|
171
|
+
DAEMON = DaemonSettings()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# =============================================================================
|
|
175
|
+
# Presence Logger Settings
|
|
176
|
+
# =============================================================================
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class PresenceSettings:
|
|
180
|
+
"""Settings for the presence logger."""
|
|
181
|
+
|
|
182
|
+
sample_interval: int = 60 # Seconds between samples
|
|
183
|
+
idle_threshold: int = 60 # Seconds before considered idle
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Global presence settings
|
|
187
|
+
PRESENCE = PresenceSettings()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# =============================================================================
|
|
191
|
+
# TUI Settings
|
|
192
|
+
# =============================================================================
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class TUISettings:
|
|
196
|
+
"""Settings for the TUI monitor."""
|
|
197
|
+
|
|
198
|
+
default_timeline_width: int = 60
|
|
199
|
+
refresh_interval: float = 1.0 # Seconds
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# Global TUI settings
|
|
203
|
+
TUI = TUISettings()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# =============================================================================
|
|
207
|
+
# Config File Loading
|
|
208
|
+
# =============================================================================
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class UserConfig:
|
|
212
|
+
"""User-configurable settings from config.yaml."""
|
|
213
|
+
|
|
214
|
+
default_standing_instructions: str = ""
|
|
215
|
+
tmux_session: str = "agents"
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def load(cls) -> "UserConfig":
|
|
219
|
+
"""Load configuration from config file."""
|
|
220
|
+
config_path = PATHS.config_file
|
|
221
|
+
|
|
222
|
+
if not config_path.exists():
|
|
223
|
+
return cls()
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
with open(config_path) as f:
|
|
227
|
+
data = yaml.safe_load(f)
|
|
228
|
+
if not isinstance(data, dict):
|
|
229
|
+
return cls()
|
|
230
|
+
|
|
231
|
+
return cls(
|
|
232
|
+
default_standing_instructions=data.get(
|
|
233
|
+
"default_standing_instructions", ""
|
|
234
|
+
),
|
|
235
|
+
tmux_session=data.get("tmux_session", "agents"),
|
|
236
|
+
)
|
|
237
|
+
except (yaml.YAMLError, IOError):
|
|
238
|
+
return cls()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Cached user config (lazy loaded)
|
|
242
|
+
_user_config: Optional[UserConfig] = None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_user_config() -> UserConfig:
|
|
246
|
+
"""Get the user configuration (cached)."""
|
|
247
|
+
global _user_config
|
|
248
|
+
if _user_config is None:
|
|
249
|
+
_user_config = UserConfig.load()
|
|
250
|
+
return _user_config
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def reload_user_config() -> UserConfig:
|
|
254
|
+
"""Reload the user configuration from disk."""
|
|
255
|
+
global _user_config
|
|
256
|
+
_user_config = UserConfig.load()
|
|
257
|
+
return _user_config
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# =============================================================================
|
|
261
|
+
# Session-Specific Paths
|
|
262
|
+
# =============================================================================
|
|
263
|
+
|
|
264
|
+
def get_session_dir(session: str) -> Path:
|
|
265
|
+
"""Get the directory for session-specific files.
|
|
266
|
+
|
|
267
|
+
Each overcode session (tmux session) gets its own subdirectory
|
|
268
|
+
for isolation. This allows running multiple overcode instances
|
|
269
|
+
(e.g., one for work, one for development).
|
|
270
|
+
|
|
271
|
+
Respects OVERCODE_STATE_DIR environment variable for test isolation.
|
|
272
|
+
"""
|
|
273
|
+
# Use get_state_dir() as base to respect OVERCODE_STATE_DIR
|
|
274
|
+
state_dir = get_state_dir()
|
|
275
|
+
# state_dir is already the sessions directory
|
|
276
|
+
return state_dir / session
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_monitor_daemon_pid_path(session: str) -> Path:
|
|
280
|
+
"""Get monitor daemon PID file path for a specific session."""
|
|
281
|
+
return get_session_dir(session) / "monitor_daemon.pid"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def get_monitor_daemon_state_path(session: str) -> Path:
|
|
285
|
+
"""Get monitor daemon state file path for a specific session."""
|
|
286
|
+
return get_session_dir(session) / "monitor_daemon_state.json"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_supervisor_daemon_pid_path(session: str) -> Path:
|
|
290
|
+
"""Get supervisor daemon PID file path for a specific session."""
|
|
291
|
+
return get_session_dir(session) / "supervisor_daemon.pid"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_agent_history_path(session: str) -> Path:
|
|
295
|
+
"""Get agent status history CSV path for a specific session."""
|
|
296
|
+
return get_session_dir(session) / "agent_status_history.csv"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def get_activity_signal_path(session: str) -> Path:
|
|
300
|
+
"""Get activity signal file path for a specific session."""
|
|
301
|
+
return get_session_dir(session) / "activity_signal"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def signal_activity(session: str = None) -> None:
|
|
305
|
+
"""Signal user activity to the daemon (called by TUI on keypress).
|
|
306
|
+
|
|
307
|
+
Creates a signal file that the daemon checks each loop.
|
|
308
|
+
When it sees this file, it wakes up and runs immediately.
|
|
309
|
+
This provides responsiveness when users interact with TUI.
|
|
310
|
+
"""
|
|
311
|
+
if session is None:
|
|
312
|
+
session = DAEMON.default_tmux_session
|
|
313
|
+
signal_path = get_activity_signal_path(session)
|
|
314
|
+
try:
|
|
315
|
+
signal_path.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
signal_path.touch()
|
|
317
|
+
except OSError:
|
|
318
|
+
pass # Best effort
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_supervisor_stats_path(session: str) -> Path:
|
|
322
|
+
"""Get supervisor stats file path for a specific session."""
|
|
323
|
+
return get_session_dir(session) / "supervisor_stats.json"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def get_supervisor_log_path(session: str) -> Path:
|
|
327
|
+
"""Get supervisor log file path for a specific session."""
|
|
328
|
+
return get_session_dir(session) / "supervisor.log"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def ensure_session_dir(session: str) -> Path:
|
|
332
|
+
"""Ensure session directory exists and return it."""
|
|
333
|
+
session_dir = get_session_dir(session)
|
|
334
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
return session_dir
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# =============================================================================
|
|
339
|
+
# Convenience Functions
|
|
340
|
+
# =============================================================================
|
|
341
|
+
|
|
342
|
+
def get_default_standing_instructions() -> str:
|
|
343
|
+
"""Get default standing instructions from config."""
|
|
344
|
+
return get_user_config().default_standing_instructions
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def get_default_tmux_session() -> str:
|
|
348
|
+
"""Get default tmux session name from config."""
|
|
349
|
+
return get_user_config().tmux_session
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# =============================================================================
|
|
353
|
+
# TUI Preferences (persisted between launches)
|
|
354
|
+
# =============================================================================
|
|
355
|
+
|
|
356
|
+
def get_tui_preferences_path(session: str) -> Path:
|
|
357
|
+
"""Get TUI preferences file path for a specific session."""
|
|
358
|
+
return get_session_dir(session) / "tui_preferences.json"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@dataclass
|
|
362
|
+
class TUIPreferences:
|
|
363
|
+
"""TUI preferences that persist between launches."""
|
|
364
|
+
|
|
365
|
+
summary_detail: str = "low" # low, med, full
|
|
366
|
+
detail_lines: int = 5 # 5, 10, 20, 50
|
|
367
|
+
timeline_visible: bool = True
|
|
368
|
+
daemon_panel_visible: bool = False
|
|
369
|
+
view_mode: str = "tree" # tree, list_preview
|
|
370
|
+
|
|
371
|
+
@classmethod
|
|
372
|
+
def load(cls, session: str) -> "TUIPreferences":
|
|
373
|
+
"""Load TUI preferences from file."""
|
|
374
|
+
import json
|
|
375
|
+
prefs_path = get_tui_preferences_path(session)
|
|
376
|
+
|
|
377
|
+
if not prefs_path.exists():
|
|
378
|
+
return cls()
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
with open(prefs_path) as f:
|
|
382
|
+
data = json.load(f)
|
|
383
|
+
if not isinstance(data, dict):
|
|
384
|
+
return cls()
|
|
385
|
+
|
|
386
|
+
return cls(
|
|
387
|
+
summary_detail=data.get("summary_detail", "low"),
|
|
388
|
+
detail_lines=data.get("detail_lines", 5),
|
|
389
|
+
timeline_visible=data.get("timeline_visible", True),
|
|
390
|
+
daemon_panel_visible=data.get("daemon_panel_visible", False),
|
|
391
|
+
view_mode=data.get("view_mode", "tree"),
|
|
392
|
+
)
|
|
393
|
+
except (json.JSONDecodeError, IOError):
|
|
394
|
+
return cls()
|
|
395
|
+
|
|
396
|
+
def save(self, session: str) -> None:
|
|
397
|
+
"""Save TUI preferences to file."""
|
|
398
|
+
import json
|
|
399
|
+
prefs_path = get_tui_preferences_path(session)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
prefs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
403
|
+
with open(prefs_path, 'w') as f:
|
|
404
|
+
json.dump({
|
|
405
|
+
"summary_detail": self.summary_detail,
|
|
406
|
+
"detail_lines": self.detail_lines,
|
|
407
|
+
"timeline_visible": self.timeline_visible,
|
|
408
|
+
"daemon_panel_visible": self.daemon_panel_visible,
|
|
409
|
+
"view_mode": self.view_mode,
|
|
410
|
+
}, f, indent=2)
|
|
411
|
+
except (IOError, OSError):
|
|
412
|
+
pass # Best effort
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standing instructions library for overcode agents.
|
|
3
|
+
|
|
4
|
+
Provides a library of pre-defined instruction presets that users can apply
|
|
5
|
+
to agents. Presets are stored in ~/.overcode/presets.json and can be
|
|
6
|
+
customized by the user.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Dict, List
|
|
13
|
+
|
|
14
|
+
PRESETS_PATH = Path.home() / ".overcode" / "presets.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class InstructionPreset:
|
|
19
|
+
"""A pre-defined standing instruction preset."""
|
|
20
|
+
name: str # Short name: DEFAULT, CODING, etc.
|
|
21
|
+
description: str # One-line description for CLI help
|
|
22
|
+
instructions: str # Full instruction text for daemon claude
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Default presets - used to generate initial presets.json
|
|
26
|
+
DEFAULT_PRESETS: Dict[str, InstructionPreset] = {
|
|
27
|
+
"DEFAULT": InstructionPreset(
|
|
28
|
+
name="DEFAULT",
|
|
29
|
+
description="General-purpose safe automation",
|
|
30
|
+
instructions=(
|
|
31
|
+
"Approve safe operations within the working directory: file reads/writes/edits, "
|
|
32
|
+
"web fetches, git status/add/commit, running tests. Reject operations outside "
|
|
33
|
+
"the project, rm -rf, or anything that could affect system stability. When "
|
|
34
|
+
"uncertain, err on the side of caution."
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
"PERMISSIVE": InstructionPreset(
|
|
38
|
+
name="PERMISSIVE",
|
|
39
|
+
description="Trusted agent, minimal friction",
|
|
40
|
+
instructions=(
|
|
41
|
+
"Approve most permission requests to keep work flowing. Trust the agent's "
|
|
42
|
+
"judgment on file operations, web access, and shell commands within reason. "
|
|
43
|
+
"Only reject clearly dangerous operations like rm -rf on large directories, "
|
|
44
|
+
"operations on system files, or commands that could crash the system."
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
"CAUTIOUS": InstructionPreset(
|
|
48
|
+
name="CAUTIOUS",
|
|
49
|
+
description="Sensitive project, careful oversight",
|
|
50
|
+
instructions=(
|
|
51
|
+
"Take a conservative approach. Approve read operations freely. For writes, "
|
|
52
|
+
"check they're within the project and make sense for the task. Reject any "
|
|
53
|
+
"git push, deployment commands, or operations that can't be easily undone. "
|
|
54
|
+
"When in doubt, let the session wait for the human user."
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
"RESEARCH": InstructionPreset(
|
|
58
|
+
name="RESEARCH",
|
|
59
|
+
description="Information gathering, exploration",
|
|
60
|
+
instructions=(
|
|
61
|
+
"Approve all read operations: file reads, web searches, web fetches, grep, "
|
|
62
|
+
"glob, directory listings. Approve writing notes or summary files. Be "
|
|
63
|
+
"cautious with code modifications - the goal is gathering information, not "
|
|
64
|
+
"making changes. Reject shell commands that modify state."
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
"CODING": InstructionPreset(
|
|
68
|
+
name="CODING",
|
|
69
|
+
description="Active development work",
|
|
70
|
+
instructions=(
|
|
71
|
+
"Approve file operations within the project: reads, writes, edits. Approve "
|
|
72
|
+
"running tests, linters, and build commands. Approve git add and commit. "
|
|
73
|
+
"Be cautious with git push - only if tests pass and work looks complete. "
|
|
74
|
+
"Reject operations outside the project directory."
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
"TESTING": InstructionPreset(
|
|
78
|
+
name="TESTING",
|
|
79
|
+
description="Running and fixing tests",
|
|
80
|
+
instructions=(
|
|
81
|
+
"Approve running test suites (pytest, jest, etc.) and viewing results. "
|
|
82
|
+
"Approve file edits to fix failing tests. Approve re-running tests after "
|
|
83
|
+
"fixes. Keep the agent focused on making tests pass. Reject unrelated "
|
|
84
|
+
"changes or scope creep beyond test fixes."
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
"REVIEW": InstructionPreset(
|
|
88
|
+
name="REVIEW",
|
|
89
|
+
description="Code review, analysis only",
|
|
90
|
+
instructions=(
|
|
91
|
+
"Approve only read operations: file reads, git log, git diff, grep searches. "
|
|
92
|
+
"The agent should analyze and report, not modify. Reject all write operations, "
|
|
93
|
+
"edits, and shell commands that change state. Let the agent provide analysis "
|
|
94
|
+
"and recommendations only."
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
"DEPLOY": InstructionPreset(
|
|
98
|
+
name="DEPLOY",
|
|
99
|
+
description="Deployment and release tasks",
|
|
100
|
+
instructions=(
|
|
101
|
+
"Approve deployment-related commands: git push, npm publish, docker build/push, "
|
|
102
|
+
"deployment scripts. Verify tests pass before approving pushes. Approve version "
|
|
103
|
+
"bumps and changelog updates. Be careful with production database commands - "
|
|
104
|
+
"verify they're read-only or explicitly requested."
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
"AUTONOMOUS": InstructionPreset(
|
|
108
|
+
name="AUTONOMOUS",
|
|
109
|
+
description="Fully autonomous operation",
|
|
110
|
+
instructions=(
|
|
111
|
+
"Approve all reasonable operations to maximize autonomous progress. Trust the "
|
|
112
|
+
"agent to make good decisions. Only intervene for clearly dangerous operations "
|
|
113
|
+
"(system file modifications, recursive deletes, credential exposure). The goal "
|
|
114
|
+
"is minimal human interruption."
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
"MINIMAL": InstructionPreset(
|
|
118
|
+
name="MINIMAL",
|
|
119
|
+
description="Just keep it from stalling",
|
|
120
|
+
instructions=(
|
|
121
|
+
"Only intervene when the agent is completely stuck on a permission prompt. "
|
|
122
|
+
"Approve simple, safe operations. For anything complex or uncertain, let it "
|
|
123
|
+
"wait for the human user. Don't provide guidance or redirect the agent - just "
|
|
124
|
+
"handle permission gates."
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _ensure_presets_file() -> None:
|
|
131
|
+
"""Create presets.json with defaults if it doesn't exist."""
|
|
132
|
+
if PRESETS_PATH.exists():
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
# Ensure directory exists
|
|
136
|
+
PRESETS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
# Write default presets
|
|
139
|
+
presets_data = {
|
|
140
|
+
name: asdict(preset)
|
|
141
|
+
for name, preset in DEFAULT_PRESETS.items()
|
|
142
|
+
}
|
|
143
|
+
with open(PRESETS_PATH, 'w') as f:
|
|
144
|
+
json.dump(presets_data, f, indent=2)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_presets() -> Dict[str, InstructionPreset]:
|
|
148
|
+
"""Load presets from ~/.overcode/presets.json.
|
|
149
|
+
|
|
150
|
+
Creates the file with defaults if it doesn't exist.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Dict mapping preset names to InstructionPreset objects
|
|
154
|
+
"""
|
|
155
|
+
_ensure_presets_file()
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with open(PRESETS_PATH, 'r') as f:
|
|
159
|
+
data = json.load(f)
|
|
160
|
+
|
|
161
|
+
presets = {}
|
|
162
|
+
for name, preset_data in data.items():
|
|
163
|
+
presets[name.upper()] = InstructionPreset(
|
|
164
|
+
name=preset_data.get("name", name),
|
|
165
|
+
description=preset_data.get("description", ""),
|
|
166
|
+
instructions=preset_data.get("instructions", ""),
|
|
167
|
+
)
|
|
168
|
+
return presets
|
|
169
|
+
|
|
170
|
+
except (json.JSONDecodeError, IOError):
|
|
171
|
+
# Fall back to defaults if file is corrupted
|
|
172
|
+
return DEFAULT_PRESETS.copy()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def save_presets(presets: Dict[str, InstructionPreset]) -> None:
|
|
176
|
+
"""Save presets to ~/.overcode/presets.json.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
presets: Dict mapping preset names to InstructionPreset objects
|
|
180
|
+
"""
|
|
181
|
+
PRESETS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
presets_data = {
|
|
184
|
+
name: asdict(preset)
|
|
185
|
+
for name, preset in presets.items()
|
|
186
|
+
}
|
|
187
|
+
with open(PRESETS_PATH, 'w') as f:
|
|
188
|
+
json.dump(presets_data, f, indent=2)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_preset(name: str) -> Optional[InstructionPreset]:
|
|
192
|
+
"""Get a preset by name (case-insensitive).
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
name: Preset name to look up
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
InstructionPreset if found, None otherwise
|
|
199
|
+
"""
|
|
200
|
+
presets = load_presets()
|
|
201
|
+
return presets.get(name.upper())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_preset_names() -> List[str]:
|
|
205
|
+
"""Get all preset names in order.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of preset names
|
|
209
|
+
"""
|
|
210
|
+
presets = load_presets()
|
|
211
|
+
# Return in a consistent order (DEFAULT first, then alphabetical)
|
|
212
|
+
names = list(presets.keys())
|
|
213
|
+
if "DEFAULT" in names:
|
|
214
|
+
names.remove("DEFAULT")
|
|
215
|
+
names = ["DEFAULT"] + sorted(names)
|
|
216
|
+
else:
|
|
217
|
+
names = sorted(names)
|
|
218
|
+
return names
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def resolve_instructions(input_text: str) -> tuple[str, Optional[str]]:
|
|
222
|
+
"""Resolve input to (full_instructions, preset_name_or_none).
|
|
223
|
+
|
|
224
|
+
If input matches a preset name (case-insensitive), returns the preset's
|
|
225
|
+
instructions and name. Otherwise returns the input as custom instructions.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
input_text: User input - either a preset name or custom instructions
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Tuple of (full_instructions, preset_name_if_used)
|
|
232
|
+
"""
|
|
233
|
+
preset = get_preset(input_text)
|
|
234
|
+
if preset:
|
|
235
|
+
return preset.instructions, preset.name
|
|
236
|
+
return input_text, None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def add_preset(name: str, description: str, instructions: str) -> None:
|
|
240
|
+
"""Add or update a preset.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
name: Preset name (will be uppercased)
|
|
244
|
+
description: Short description
|
|
245
|
+
instructions: Full instruction text
|
|
246
|
+
"""
|
|
247
|
+
presets = load_presets()
|
|
248
|
+
presets[name.upper()] = InstructionPreset(
|
|
249
|
+
name=name.upper(),
|
|
250
|
+
description=description,
|
|
251
|
+
instructions=instructions,
|
|
252
|
+
)
|
|
253
|
+
save_presets(presets)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def remove_preset(name: str) -> bool:
|
|
257
|
+
"""Remove a preset.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
name: Preset name to remove
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
True if preset was removed, False if not found
|
|
264
|
+
"""
|
|
265
|
+
presets = load_presets()
|
|
266
|
+
name_upper = name.upper()
|
|
267
|
+
if name_upper in presets:
|
|
268
|
+
del presets[name_upper]
|
|
269
|
+
save_presets(presets)
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def reset_presets() -> None:
|
|
275
|
+
"""Reset presets to defaults."""
|
|
276
|
+
save_presets(DEFAULT_PRESETS.copy())
|