Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,13 @@ Between swarms, agents drift back to invisible inline work. Reminders decay, so
Run one command:

```bash
./ringer.py install-agent
./ringer.py install-agent # Claude Code
./ringer.py install-agent --agent codex # Codex CLI / IDE
```

It installs the ringer skill — the orchestrator playbook — user-level for Claude Code, and registers two gentle hooks: a Bash hook that notices model-calling or harness commands running outside a live Ringer run, and an edit-loop hook that notices batch editing without a run. Each hook nudges ONCE per session, pointing the agent at the skill.
It installs the ringer skill — the orchestrator playbook — and registers two gentle hooks. Codex installs the skill under `~/.agents/skills/ringer` and hooks under `~/.codex/hooks.json`; its post-tool matcher targets the real `apply_patch` event. On Windows, Codex hook entries include `commandWindows`. Each hook nudges once per session, pointing the agent at the skill.

The hooks never block anything. A user who says "just do it inline" is obeyed; uninstall with `./ringer.py uninstall-agent`.
The hooks never block anything. A user who says "just do it inline" is obeyed; uninstall with `./ringer.py uninstall-agent` or `./ringer.py uninstall-agent --agent codex`.

For CI and evals, `config.sample.toml` includes `[engines.mock]` so the enforcement stack can be tested without an API bill.

Expand Down
56 changes: 51 additions & 5 deletions hooks/ringer_nudge.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
r"\.(?:mjs|js|ts|py)\b",
re.IGNORECASE,
)
PATCH_PATH_RE = re.compile(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", re.MULTILINE)
PATCH_MOVE_RE = re.compile(r"^\*\*\* Move to: (.+)$", re.MULTILINE)


def ringer_home() -> Path:
Expand All @@ -38,13 +40,44 @@ def ringer_home() -> Path:
return Path.home() / ".ringer"


def pid_is_alive_windows(pid: int) -> bool:
import ctypes
from ctypes import wintypes

synchronize = 0x00100000
wait_timeout = 0x00000102
error_access_denied = 5

kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
open_process = kernel32.OpenProcess
open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
open_process.restype = wintypes.HANDLE
wait_for_single_object = kernel32.WaitForSingleObject
wait_for_single_object.argtypes = [wintypes.HANDLE, wintypes.DWORD]
wait_for_single_object.restype = wintypes.DWORD
close_handle = kernel32.CloseHandle
close_handle.argtypes = [wintypes.HANDLE]
close_handle.restype = wintypes.BOOL

handle = open_process(synchronize, False, pid)
if not handle:
return ctypes.get_last_error() == error_access_denied
try:
return wait_for_single_object(handle, 0) == wait_timeout
finally:
close_handle(handle)


def pid_is_alive(pid: Any) -> bool:
try:
parsed = int(pid)
except (TypeError, ValueError):
return False
if parsed <= 0:
return False
if os.name == "nt":
# Signal 0 is CTRL_C_EVENT on Windows, not a harmless existence probe.
return pid_is_alive_windows(parsed)
try:
os.kill(parsed, 0)
except ProcessLookupError:
Expand Down Expand Up @@ -178,17 +211,30 @@ def load_post_edit_state(path: Path) -> dict[str, Any]:
return {"count": count, "file_paths": [str(path) for path in file_paths]}


def edited_file_paths(payload: dict[str, Any]) -> set[str]:
tool_input = payload.get("tool_input")
if not isinstance(tool_input, dict):
return set()

file_path = tool_input.get("file_path")
if isinstance(file_path, str) and file_path.strip():
return {file_path.strip()}

command = tool_input.get("command")
if payload.get("tool_name") != "apply_patch" or not isinstance(command, str):
return set()
paths = PATCH_PATH_RE.findall(command)
paths.extend(PATCH_MOVE_RE.findall(command))
return {path.strip() for path in paths if path.strip()}


def record_post_edit(payload: dict[str, Any], home: Path) -> tuple[int, int]:
path = post_edit_state_path(home, payload.get("session_id"))
state = load_post_edit_state(path)
count = int(state["count"]) + 1
files = set(str(item) for item in state["file_paths"])

tool_input = payload.get("tool_input")
if isinstance(tool_input, dict):
file_path = tool_input.get("file_path")
if isinstance(file_path, str) and file_path.strip():
files.add(file_path)
files.update(edited_file_paths(payload))

next_state = {"count": count, "file_paths": sorted(files)}
write_json_atomic(path, next_state)
Expand Down
140 changes: 94 additions & 46 deletions ringer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9469,8 +9469,20 @@ def repo_root() -> Path:
return Path(__file__).resolve().parent


def agent_base(project: bool) -> Path:
return Path.cwd() if project else Path.home()


def claude_root(project: bool) -> Path:
return (Path.cwd() if project else Path.home()) / ".claude"
return agent_base(project) / ".claude"


def codex_skill_root(project: bool) -> Path:
return agent_base(project) / ".agents"


def codex_config_root(project: bool) -> Path:
return agent_base(project) / ".codex"


def ringer_skill_source() -> Path:
Expand All @@ -9479,7 +9491,13 @@ def ringer_skill_source() -> Path:

def ringer_hook_command(action: str) -> str:
hook_path = repo_root() / "hooks" / "ringer_nudge.py"
return f"python3 {shlex.quote(str(hook_path))} {action}"
return shlex.join(["python3", str(hook_path), action])


def ringer_hook_command_windows(action: str) -> str:
hook_path = repo_root() / "hooks" / "ringer_nudge.py"
python_command = [sys.executable] if os.name == "nt" else ["py", "-3"]
return subprocess.list2cmdline([*python_command, str(hook_path), action])


def backup_file(path: Path) -> Path | None:
Expand Down Expand Up @@ -9515,38 +9533,45 @@ def hook_command_contains(value: Any, needle: str = "ringer_nudge.py") -> bool:
return isinstance(value, dict) and needle in str(value.get("command", ""))


def event_has_ringer_hook(groups: Any) -> bool:
if not isinstance(groups, list):
return False
for group in groups:
if not isinstance(group, dict):
continue
handlers = group.get("hooks")
if isinstance(handlers, list) and any(hook_command_contains(handler) for handler in handlers):
return True
return False


def merge_ringer_hook(settings: dict[str, Any], event: str, matcher: str, command: str) -> bool:
def merge_ringer_hook(
settings: dict[str, Any],
event: str,
matcher: str,
command: str,
*,
command_windows: str | None = None,
) -> bool:
hooks = settings.setdefault("hooks", {})
if not isinstance(hooks, dict):
raise ValueError("settings hooks field must be a JSON object")
groups = hooks.setdefault(event, [])
if not isinstance(groups, list):
raise ValueError(f"settings hooks.{event} field must be a JSON array")
if event_has_ringer_hook(groups):
return False
groups.append(
{
"matcher": matcher,
"hooks": [
{
"type": "command",
"command": command,
}
],
}
)
for group in groups:
if not isinstance(group, dict):
continue
handlers = group.get("hooks")
if not isinstance(handlers, list):
continue
ringer_handlers = [handler for handler in handlers if hook_command_contains(handler)]
if not ringer_handlers:
continue
desired = {"type": "command", "command": command}
if command_windows:
desired["commandWindows"] = command_windows
changed = group.get("matcher") != matcher or ringer_handlers != [desired]
if changed:
group["matcher"] = matcher
group["hooks"] = [
desired if hook_command_contains(handler) else handler
for handler in handlers
]
return changed

handler = {"type": "command", "command": command}
if command_windows:
handler["commandWindows"] = command_windows
groups.append({"matcher": matcher, "hooks": [handler]})
return True


Expand Down Expand Up @@ -9587,61 +9612,82 @@ def remove_ringer_hooks(settings: dict[str, Any]) -> int:
return removed


def install_agent(project: bool = False) -> int:
root = claude_root(project)
def install_agent(project: bool = False, agent: str = "claude") -> int:
if agent == "claude":
skill_root = claude_root(project)
settings_path = skill_root / "settings.json"
post_matcher = "Edit|Write"
elif agent == "codex":
skill_root = codex_skill_root(project)
settings_path = codex_config_root(project) / "hooks.json"
post_matcher = "apply_patch"
else:
raise ValueError(f"unsupported agent: {agent}")

skill_source = ringer_skill_source()
skill_target = root / "skills" / "ringer" / "SKILL.md"
skill_target = skill_root / "skills" / "ringer" / "SKILL.md"
if not skill_source.exists():
raise ValueError(f"ringer skill source not found: {skill_source}")
skill_target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(skill_source, skill_target)

settings_path = root / "settings.json"
settings = load_settings(settings_path)
changed = False
changed |= merge_ringer_hook(
settings,
"PreToolUse",
"Bash",
ringer_hook_command("pre-bash"),
command_windows=(
ringer_hook_command_windows("pre-bash") if agent == "codex" else None
),
)
changed |= merge_ringer_hook(
settings,
"PostToolUse",
"Edit|Write",
post_matcher,
ringer_hook_command("post-edit"),
command_windows=(
ringer_hook_command_windows("post-edit") if agent == "codex" else None
),
)
if changed or not settings_path.exists():
write_settings(settings_path, settings)

scope = "project" if project else "user"
print(f"Installed ringer agent for {scope} scope.")
print(f"Installed ringer agent for {agent} ({scope} scope).")
print(f"Skill: {skill_target}")
if changed:
print(f"Hooks: added PreToolUse Bash and PostToolUse Edit|Write in {settings_path}")
print(f"Hooks: added PreToolUse Bash and PostToolUse {post_matcher} in {settings_path}")
else:
print(f"Hooks: already present in {settings_path}")
return 0


def uninstall_agent(project: bool = False) -> int:
root = claude_root(project)
settings_path = root / "settings.json"
def uninstall_agent(project: bool = False, agent: str = "claude") -> int:
if agent == "claude":
skill_root = claude_root(project)
settings_path = skill_root / "settings.json"
elif agent == "codex":
skill_root = codex_skill_root(project)
settings_path = codex_config_root(project) / "hooks.json"
else:
raise ValueError(f"unsupported agent: {agent}")
removed_hooks = 0
if settings_path.exists():
settings = load_settings(settings_path)
removed_hooks = remove_ringer_hooks(settings)
if removed_hooks:
write_settings(settings_path, settings)

skill_dir = root / "skills" / "ringer"
skill_dir = skill_root / "skills" / "ringer"
removed_skill = False
if skill_dir.exists():
shutil.rmtree(skill_dir)
removed_skill = True

scope = "project" if project else "user"
print(f"Uninstalled ringer agent for {scope} scope.")
print(f"Uninstalled ringer agent for {agent} ({scope} scope).")
print(f"Hooks removed: {removed_hooks}")
print(f"Skill removed: {'yes' if removed_skill else 'no'}")
return 0
Expand Down Expand Up @@ -9997,11 +10043,13 @@ def build_parser() -> argparse.ArgumentParser:
)
demo_parser.add_argument("--dry-run", action="store_true", help="print the demo plan without spawning codex")

install_parser = subparsers.add_parser("install-agent", help="install the ringer Claude Code skill and hooks")
install_parser.add_argument("--project", action="store_true", help="install into ./.claude instead of ~/.claude")
install_parser = subparsers.add_parser("install-agent", help="install the ringer orchestrator skill and hooks")
install_parser.add_argument("--agent", choices=("claude", "codex"), default="claude", help="orchestrating agent to configure (default: claude)")
install_parser.add_argument("--project", action="store_true", help="install at project scope instead of user scope")

uninstall_parser = subparsers.add_parser("uninstall-agent", help="remove the ringer Claude Code skill and hooks")
uninstall_parser.add_argument("--project", action="store_true", help="remove from ./.claude instead of ~/.claude")
uninstall_parser = subparsers.add_parser("uninstall-agent", help="remove the ringer orchestrator skill and hooks")
uninstall_parser.add_argument("--agent", choices=("claude", "codex"), default="claude", help="orchestrating agent to clean up (default: claude)")
uninstall_parser.add_argument("--project", action="store_true", help="remove from project scope instead of user scope")
return parser


Expand Down Expand Up @@ -10047,9 +10095,9 @@ def main(argv: list[str] | None = None) -> int:
print(f"Self-update skipped: {result.reason or 'not available'}.")
return 0
if args.command == "install-agent":
return install_agent(project=args.project)
return install_agent(project=args.project, agent=args.agent)
if args.command == "uninstall-agent":
return uninstall_agent(project=args.project)
return uninstall_agent(project=args.project, agent=args.agent)

if args.command == "lint":
manifest = Manifest.from_path(args.manifest)
Expand Down
Loading