From d53647617d6164a5998142ba7a46ba79eb62788f Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Tue, 27 Jan 2026 08:30:05 +0100 Subject: [PATCH 1/6] feat: add architect hook installers Issue: Add built-in architect hook commands for Claude/Codex/Gemini and require lint in the workflow. Solution: Extend the embedded architect helper with hook subcommands that update configs idempotently. Solution: Update Gemini hook wrapper to prefer architect notify when available. Solution: Document the installer flow in AI integration docs and note lint in CLAUDE.md. --- CLAUDE.md | 2 +- README.md | 2 +- docs/ai-integration.md | 39 ++- docs/architecture.md | 4 + scripts/architect_hook_gemini.py | 27 +- src/shell.zig | 466 +++++++++++++++++++++++++++++++ 6 files changed, 523 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b05eb65..b3ba703 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ while (pos < slice.len) { ... } The `<= len` pattern is only correct when `pos` represents a position *after* processing (e.g., `slice[0..pos]` as a "processed so far" marker). ## Build and Test (required after every task) -- Run `zig build` and `zig build test` (or `just ci` when appropriate) once the task is complete. +- Run `zig build`, `zig build test`, and `just lint` (or `just ci` when appropriate) once the task is complete. - Report the results in your summary; if you must skip tests, state the reason explicitly. ## Documentation Hygiene (REQUIRED) diff --git a/README.md b/README.md index a3cee78..f46449b 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ Common settings include font family, theme colors, and grid font scale. The grid ## Documentation -* [`docs/ai-integration.md`](docs/ai-integration.md): set up Claude Code, Codex, and Gemini CLI hooks for status notifications. +* [`docs/ai-integration.md`](docs/ai-integration.md): set up Claude Code, Codex, and Gemini CLI hooks for status notifications (includes `architect notify` and `architect hook ...` inside Architect terminals). * [`docs/architecture.md`](docs/architecture.md): architecture overview and system boundaries. * [`docs/configuration.md`](docs/configuration.md): detailed configuration reference for `config.toml` and `persistence.toml`. * [`docs/development.md`](docs/development.md): build, test, and release process. diff --git a/docs/ai-integration.md b/docs/ai-integration.md index 87684f4..828e0a3 100644 --- a/docs/ai-integration.md +++ b/docs/ai-integration.md @@ -15,9 +15,33 @@ Examples: {"session": 0, "state": "done"} ``` +## Built-in Command (inside Architect terminals) + +Architect injects a small `architect` command into each shell's `PATH`. It reads the +session id and socket path from the environment, so hooks can simply call: + +```bash +architect notify start +architect notify awaiting_approval +architect notify done +``` + +If your hook runs outside an Architect terminal, use the Python helper scripts below. +Replace `architect notify ...` in the examples with `python3 ~/./architect_notify.py ...` when using those scripts. + +## Hook Installer + +From inside an Architect terminal, you can install hooks automatically: + +```bash +architect hook claude +architect hook codex +architect hook gemini +``` + ## Claude Code Hooks -1. Copy the helper script: +1. (Optional) Copy the helper script if the hook runs outside Architect: ```bash cp scripts/architect_notify.py ~/.claude/architect_notify.py chmod +x ~/.claude/architect_notify.py @@ -32,7 +56,7 @@ Examples: "hooks": [ { "type": "command", - "command": "python3 ~/.claude/architect_notify.py done || true" + "command": "architect notify done || true" } ] } @@ -42,7 +66,7 @@ Examples: "hooks": [ { "type": "command", - "command": "python3 ~/.claude/architect_notify.py awaiting_approval || true" + "command": "architect notify awaiting_approval || true" } ] } @@ -53,7 +77,7 @@ Examples: ## Codex Hooks -1. Copy the helper script: +1. (Optional) Copy the helper script if the hook runs outside Architect: ```bash cp scripts/architect_notify.py ~/.codex/architect_notify.py chmod +x ~/.codex/architect_notify.py @@ -61,12 +85,15 @@ Examples: 2. Add the `notify` setting to `~/.codex/config.toml`: ```toml - notify = ["python3", "/Users/your-username/.codex/architect_notify.py"] + notify = ["architect", "notify"] ``` ## Gemini CLI Hooks -1. Copy the notification scripts: +Gemini hooks must emit JSON to stdout, so keep using the wrapper script even inside +Architect terminals (it can call `architect notify` under the hood). + +1. Copy the notification scripts (the `architect hook gemini` installer assumes they exist): ```bash cp scripts/architect_notify.py ~/.gemini/architect_notify.py cp scripts/architect_hook_gemini.py ~/.gemini/architect_hook.py diff --git a/docs/architecture.md b/docs/architecture.md index b9a2d5d..3997d4a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -323,6 +323,10 @@ Protocol: Single-line JSON {"session": 0, "state": "start"} ``` +Each shell also gets a small `architect` command in `PATH` that wraps this protocol +and reads `ARCHITECT_SESSION_ID`/`ARCHITECT_NOTIFY_SOCK` (for example, +`architect notify done`). + A background thread (`notify.zig`) accepts connections, parses messages, and pushes to a thread-safe `NotificationQueue`. Main loop drains queue each frame. ## First Frame Guard Pattern diff --git a/scripts/architect_hook_gemini.py b/scripts/architect_hook_gemini.py index 8a5458c..cb77e47 100755 --- a/scripts/architect_hook_gemini.py +++ b/scripts/architect_hook_gemini.py @@ -3,13 +3,14 @@ Gemini CLI hook wrapper for Architect notifications. Gemini hooks receive JSON via stdin and must output JSON to stdout. -This wrapper consumes stdin, calls architect_notify.py, and returns valid JSON. +This wrapper consumes stdin, calls Architect notify, and returns valid JSON. """ import json import os -import sys +import shutil import subprocess +import sys def main() -> int: @@ -29,13 +30,21 @@ def main() -> int: state = sys.argv[1] - # Call architect_notify.py script - script_path = os.path.join(os.path.dirname(__file__), "architect_notify.py") - subprocess.run( - ["python3", script_path, state], - check=False, - capture_output=True, - ) + # Prefer built-in architect command when available, fall back to script. + architect_cmd = shutil.which("architect") + if architect_cmd: + subprocess.run( + [architect_cmd, "notify", state], + check=False, + capture_output=True, + ) + else: + script_path = os.path.join(os.path.dirname(__file__), "architect_notify.py") + subprocess.run( + ["python3", script_path, state], + check=False, + capture_output=True, + ) # Return success JSON to Gemini (required) print(json.dumps({"decision": "allow"})) diff --git a/src/shell.zig b/src/shell.zig index 8fe7a23..f5f5ac2 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -25,6 +25,10 @@ var terminfo_available: bool = false; var terminfo_dir_buf: [std.fs.max_path_bytes]u8 = undefined; var terminfo_dir_z: ?[:0]const u8 = null; var tic_path_buf: [std.fs.max_path_bytes]u8 = undefined; +var architect_command_setup_done: bool = false; +var architect_command_dir_buf: [std.fs.max_path_bytes]u8 = undefined; +var architect_command_path_buf: [std.fs.max_path_bytes]u8 = undefined; +var architect_command_dir_z: ?[:0]const u8 = null; const fallback_term = "xterm-256color"; const architect_term = "xterm-ghostty"; @@ -34,6 +38,350 @@ const default_term_program = "Architect"; // Architect terminfo: xterm-256color base + 24-bit truecolor + kitty keyboard protocol const architect_terminfo_src = assets.xterm_ghostty; +const architect_command_script = + \\#!/usr/bin/env python3 + \\""" + \\Architect shell helper for sending UI notifications and installing hooks. + \\ + \\Usage: + \\ architect notify + \\ architect notify (reads stdin) + \\ architect hook claude|codex|gemini + \\""" + \\import json + \\import os + \\import socket + \\import sys + \\ + \\try: + \\ import tomllib + \\except Exception: + \\ tomllib = None + \\ + \\VALID_STATES = {"start", "awaiting_approval", "done"} + \\ + \\CLAUDE_DONE = "architect notify done || true" + \\CLAUDE_APPROVAL = "architect notify awaiting_approval || true" + \\CLAUDE_NEEDLES = ("architect notify", "architect_notify.py") + \\ + \\GEMINI_DONE = "python3 ~/.gemini/architect_hook.py done" + \\GEMINI_APPROVAL = "python3 ~/.gemini/architect_hook.py awaiting_approval" + \\GEMINI_NEEDLES = ("architect_hook.py", "architect notify") + \\ + \\CODEX_NOTIFY = ["architect", "notify"] + \\ + \\def notify_architect(state: str) -> None: + \\ session_id = os.environ.get("ARCHITECT_SESSION_ID") + \\ sock_path = os.environ.get("ARCHITECT_NOTIFY_SOCK") + \\ + \\ if not session_id or not sock_path: + \\ return + \\ + \\ try: + \\ message = json.dumps({ + \\ "session": int(session_id), + \\ "state": state + \\ }) + "\\n" + \\ + \\ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + \\ sock.connect(sock_path) + \\ sock.sendall(message.encode()) + \\ sock.close() + \\ except Exception: + \\ pass + \\ + \\def state_from_notification(raw: str) -> str | None: + \\ raw = raw.strip() + \\ if not raw: + \\ return None + \\ + \\ if raw in VALID_STATES: + \\ return raw + \\ + \\ try: + \\ payload = json.loads(raw) + \\ except Exception: + \\ return None + \\ + \\ if not isinstance(payload, dict): + \\ return None + \\ + \\ state_field = payload.get("state") + \\ if isinstance(state_field, str) and state_field in VALID_STATES: + \\ return state_field + \\ + \\ status = payload.get("status") + \\ if isinstance(status, str): + \\ lowered = status.lower() + \\ if lowered in VALID_STATES: + \\ return lowered + \\ if lowered in ("complete", "completed", "finished", "success"): + \\ return "done" + \\ if "approval" in lowered or "permission" in lowered: + \\ return "awaiting_approval" + \\ + \\ ntype = str(payload.get("type") or "").lower() + \\ if ntype: + \\ if ntype in VALID_STATES: + \\ return ntype + \\ if "approval" in ntype or "permission" in ntype or ( + \\ "input" in ntype and "await" in ntype + \\ ): + \\ return "awaiting_approval" + \\ if "complete" in ntype or ntype.endswith("-done"): + \\ return "done" + \\ if "start" in ntype or "begin" in ntype: + \\ return "start" + \\ + \\ return None + \\ + \\def warn_unmapped(raw: str) -> None: + \\ if sys.stderr.isatty(): + \\ print(f"Ignoring unmapped notification: {raw}", file=sys.stderr) + \\ + \\def print_usage() -> None: + \\ if sys.stderr.isatty(): + \\ print("Usage: architect notify ", file=sys.stderr) + \\ print(" architect notify (reads stdin)", file=sys.stderr) + \\ print(" architect hook claude|codex|gemini", file=sys.stderr) + \\ + \\def read_text(path: str) -> str | None: + \\ try: + \\ with open(path, "r", encoding="utf-8") as handle: + \\ return handle.read() + \\ except FileNotFoundError: + \\ return None + \\ + \\def write_text(path: str, text: str) -> None: + \\ with open(path, "w", encoding="utf-8") as handle: + \\ handle.write(text) + \\ + \\def load_json(path: str) -> dict | None: + \\ text = read_text(path) + \\ if text is None: + \\ return None + \\ try: + \\ return json.loads(text) + \\ except Exception: + \\ return None + \\ + \\def write_json(path: str, data: dict) -> None: + \\ write_text(path, json.dumps(data, indent=2, sort_keys=False) + "\\n") + \\ + \\def command_has_needle(command: str, needles: tuple[str, ...]) -> bool: + \\ return any(needle in command for needle in needles) + \\ + \\def hooks_have_needles(groups, needles: tuple[str, ...]) -> bool: + \\ if not isinstance(groups, list): + \\ return False + \\ for group in groups: + \\ hooks = group.get("hooks") if isinstance(group, dict) else None + \\ if not isinstance(hooks, list): + \\ continue + \\ for hook in hooks: + \\ if not isinstance(hook, dict): + \\ continue + \\ cmd = hook.get("command") + \\ if isinstance(cmd, str) and command_has_needle(cmd, needles): + \\ return True + \\ return False + \\ + \\def ensure_group(groups): + \\ if not groups: + \\ group = {"hooks": []} + \\ groups.append(group) + \\ return group + \\ group = groups[0] + \\ if not isinstance(group, dict): + \\ group = {"hooks": []} + \\ groups[0] = group + \\ if not isinstance(group.get("hooks"), list): + \\ group["hooks"] = [] + \\ return group + \\ + \\def ensure_matcher_group(groups): + \\ if not groups: + \\ group = {"matcher": "*", "hooks": []} + \\ groups.append(group) + \\ return group + \\ for group in groups: + \\ if isinstance(group, dict) and group.get("matcher") == "*": + \\ if not isinstance(group.get("hooks"), list): + \\ group["hooks"] = [] + \\ return group + \\ group = {"matcher": "*", "hooks": []} + \\ groups.append(group) + \\ return group + \\ + \\def ensure_claude_hooks(data: dict) -> bool: + \\ hooks = data.setdefault("hooks", {}) + \\ if not isinstance(hooks, dict): + \\ hooks = {} + \\ data["hooks"] = hooks + \\ stop_groups = hooks.setdefault("Stop", []) + \\ notification_groups = hooks.setdefault("Notification", []) + \\ changed = False + \\ + \\ if not hooks_have_needles(stop_groups, CLAUDE_NEEDLES): + \\ group = ensure_group(stop_groups) + \\ group["hooks"].append({"type": "command", "command": CLAUDE_DONE}) + \\ changed = True + \\ + \\ if not hooks_have_needles(notification_groups, CLAUDE_NEEDLES): + \\ group = ensure_group(notification_groups) + \\ group["hooks"].append({"type": "command", "command": CLAUDE_APPROVAL}) + \\ changed = True + \\ + \\ return changed + \\ + \\def ensure_gemini_hooks(data: dict) -> bool: + \\ hooks = data.setdefault("hooks", {}) + \\ if not isinstance(hooks, dict): + \\ hooks = {} + \\ data["hooks"] = hooks + \\ after_groups = hooks.setdefault("AfterAgent", []) + \\ notification_groups = hooks.setdefault("Notification", []) + \\ changed = False + \\ + \\ if not hooks_have_needles(after_groups, GEMINI_NEEDLES): + \\ group = ensure_matcher_group(after_groups) + \\ group["hooks"].append({ + \\ "name": "architect-completion", + \\ "type": "command", + \\ "command": GEMINI_DONE, + \\ "description": "Notify Architect when task completes", + \\ }) + \\ changed = True + \\ + \\ if not hooks_have_needles(notification_groups, GEMINI_NEEDLES): + \\ group = ensure_matcher_group(notification_groups) + \\ group["hooks"].append({ + \\ "name": "architect-approval", + \\ "type": "command", + \\ "command": GEMINI_APPROVAL, + \\ "description": "Notify Architect when waiting for approval", + \\ }) + \\ changed = True + \\ + \\ tools = data.setdefault("tools", {}) + \\ if not isinstance(tools, dict): + \\ tools = {} + \\ data["tools"] = tools + \\ if tools.get("enableHooks") is not True: + \\ tools["enableHooks"] = True + \\ changed = True + \\ + \\ return changed + \\ + \\def codex_notify_installed(text: str) -> bool: + \\ if tomllib is not None: + \\ try: + \\ data = tomllib.loads(text) + \\ value = data.get("notify") + \\ if isinstance(value, list): + \\ if value == CODEX_NOTIFY: + \\ return True + \\ for item in value: + \\ if isinstance(item, str) and "architect_notify.py" in item: + \\ return True + \\ except Exception: + \\ pass + \\ if 'notify = ["architect", "notify"]' in text: + \\ return True + \\ if "architect_notify.py" in text: + \\ return True + \\ return False + \\ + \\def upsert_notify_line(text: str, line: str) -> str: + \\ lines = text.splitlines() + \\ for i, existing in enumerate(lines): + \\ if existing.strip().startswith("notify"): + \\ lines[i] = line + \\ return "\\n".join(lines) + "\\n" + \\ if lines and lines[-1].strip() != "": + \\ lines.append("") + \\ lines.append(line) + \\ return "\\n".join(lines) + "\\n" + \\ + \\def install_claude() -> int: + \\ path = os.path.expanduser("~/.claude/settings.json") + \\ data = load_json(path) + \\ if data is None: + \\ print(f"Failed to read {path}", file=sys.stderr) + \\ return 1 + \\ if ensure_claude_hooks(data): + \\ write_json(path, data) + \\ print("Installed Claude hooks.") + \\ else: + \\ print("Claude hooks already installed.") + \\ return 0 + \\ + \\def install_gemini() -> int: + \\ path = os.path.expanduser("~/.gemini/settings.json") + \\ data = load_json(path) + \\ if data is None: + \\ print(f"Failed to read {path}", file=sys.stderr) + \\ return 1 + \\ if ensure_gemini_hooks(data): + \\ write_json(path, data) + \\ print("Installed Gemini hooks.") + \\ else: + \\ print("Gemini hooks already installed.") + \\ return 0 + \\ + \\def install_codex() -> int: + \\ path = os.path.expanduser("~/.codex/config.toml") + \\ text = read_text(path) + \\ if text is None: + \\ print(f"Failed to read {path}", file=sys.stderr) + \\ return 1 + \\ if codex_notify_installed(text): + \\ print("Codex hooks already installed.") + \\ return 0 + \\ notify_line = 'notify = ["architect", "notify"]' + \\ new_text = upsert_notify_line(text, notify_line) + \\ write_text(path, new_text) + \\ print("Installed Codex hooks.") + \\ return 0 + \\ + \\def main() -> int: + \\ if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"): + \\ print_usage() + \\ return 1 + \\ + \\ cmd = sys.argv[1] + \\ if cmd == "notify": + \\ raw_arg = sys.argv[2] if len(sys.argv) >= 3 else sys.stdin.read() + \\ if not raw_arg.strip(): + \\ print_usage() + \\ return 1 + \\ state = state_from_notification(raw_arg) + \\ if state is None: + \\ warn_unmapped(raw_arg) + \\ return 0 + \\ notify_architect(state) + \\ return 0 + \\ if cmd == "hook": + \\ if len(sys.argv) < 3: + \\ print_usage() + \\ return 1 + \\ sub = sys.argv[2] + \\ if sub == "claude": + \\ return install_claude() + \\ if sub == "codex": + \\ return install_codex() + \\ if sub == "gemini": + \\ return install_gemini() + \\ print_usage() + \\ return 1 + \\ + \\ print_usage() + \\ return 1 + \\ + \\if __name__ == "__main__": + \\ raise SystemExit(main()) + \\ +; fn setDefaultEnv(name: [:0]const u8, value: [:0]const u8) void { if (posix.getenv(name) != null) return; @@ -161,6 +509,114 @@ pub fn ensureTerminfoSetup() void { } } +fn ensureArchitectCommandSetup() void { + if (architect_command_setup_done) return; + architect_command_setup_done = true; + + const runtime_dir = posix.getenv("XDG_RUNTIME_DIR"); + const home = posix.getenv("HOME"); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const base_dir = if (runtime_dir) |dir| + std.fmt.bufPrint(&base_buf, "{s}/architect", .{dir}) catch |err| { + log.warn("failed to format architect runtime path: {}", .{err}); + return; + } + else if (home) |home_dir| + std.fmt.bufPrint(&base_buf, "{s}/.cache/architect", .{home_dir}) catch |err| { + log.warn("failed to format architect cache path: {}", .{err}); + return; + } + else + "/tmp/architect"; + + std.fs.makeDirAbsolute(base_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + log.warn("failed to create architect command base dir: {}", .{err}); + return; + }, + }; + + const bin_dir_z = std.fmt.bufPrintZ(&architect_command_dir_buf, "{s}/bin", .{base_dir}) catch |err| { + log.warn("failed to format architect bin path: {}", .{err}); + return; + }; + const bin_dir = bin_dir_z[0..bin_dir_z.len]; + + std.fs.makeDirAbsolute(bin_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + log.warn("failed to create architect bin dir: {}", .{err}); + return; + }, + }; + + const script_path_z = std.fmt.bufPrintZ(&architect_command_path_buf, "{s}/architect", .{bin_dir}) catch |err| { + log.warn("failed to format architect command path: {}", .{err}); + return; + }; + + const script_file = std.fs.createFileAbsolute(script_path_z, .{ .truncate = true }) catch |err| { + log.warn("failed to create architect command: {}", .{err}); + return; + }; + defer script_file.close(); + + script_file.writeAll(architect_command_script) catch |err| { + log.warn("failed to write architect command: {}", .{err}); + return; + }; + + const script_path = std.mem.sliceTo(script_path_z, 0); + posix.fchmodat(posix.AT.FDCWD, script_path, 0o755, 0) catch |err| { + log.warn("failed to chmod architect command: {}", .{err}); + }; + + architect_command_dir_z = bin_dir_z; +} + +fn pathContainsEntry(path: []const u8, entry: []const u8) bool { + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |segment| { + if (std.mem.eql(u8, segment, entry)) return true; + } + return false; +} + +fn ensureArchitectCommandPath() void { + const dir_z = architect_command_dir_z orelse return; + const dir = std.mem.sliceTo(dir_z, 0); + + const path_env = posix.getenv("PATH") orelse ""; + const path_slice = std.mem.sliceTo(path_env, 0); + if (pathContainsEntry(path_slice, dir)) return; + + const needs_sep = path_slice.len > 0; + const sep_len: usize = if (needs_sep) 1 else 0; + const total_len = dir.len + sep_len + path_slice.len; + const buf = std.heap.c_allocator.alloc(u8, total_len + 1) catch |err| { + log.warn("failed to allocate PATH for architect command: {}", .{err}); + return; + }; + defer std.heap.c_allocator.free(buf); + + var idx: usize = 0; + std.mem.copyForwards(u8, buf[idx..][0..dir.len], dir); + idx += dir.len; + if (needs_sep) { + buf[idx] = ':'; + idx += 1; + std.mem.copyForwards(u8, buf[idx..][0..path_slice.len], path_slice); + idx += path_slice.len; + } + buf[idx] = 0; + + const value_z: [:0]u8 = buf[0..idx :0]; + if (libc.setenv("PATH", value_z.ptr, 1) != 0) { + log.warn("failed to set PATH for architect command", .{}); + } +} + fn findExecutableInPath(name: []const u8) ?[:0]const u8 { const path_env = posix.getenv("PATH") orelse return null; const path_env_slice = std.mem.sliceTo(path_env, 0); @@ -193,6 +649,7 @@ pub const Shell = struct { pub fn spawn(shell_path: []const u8, size: pty_mod.winsize, session_id: [:0]const u8, notify_sock: [:0]const u8, working_dir: ?[:0]const u8) SpawnError!Shell { // Ensure terminfo is set up (parent process, before fork) ensureTerminfoSetup(); + ensureArchitectCommandSetup(); const pty_instance = try pty_mod.Pty.open(size); errdefer { @@ -230,6 +687,7 @@ pub const Shell = struct { setDefaultEnv("COLORTERM", default_colorterm); setDefaultEnv("LANG", default_lang); setDefaultEnv("TERM_PROGRAM", default_term_program); + ensureArchitectCommandPath(); // Change to specified directory or home directory before spawning shell. // Try working_dir first, fall back to HOME. @@ -309,3 +767,11 @@ pub const Shell = struct { _ = std.c.waitpid(self.child_pid, null, 0); } }; + +test "pathContainsEntry" { + try std.testing.expect(pathContainsEntry("/usr/bin:/opt/bin", "/usr/bin")); + try std.testing.expect(pathContainsEntry("/usr/bin:/opt/bin", "/opt/bin")); + try std.testing.expect(!pathContainsEntry("/usr/bin:/opt/bin", "/bin")); + try std.testing.expect(pathContainsEntry("/usr/bin", "/usr/bin")); + try std.testing.expect(!pathContainsEntry("", "/usr/bin")); +} From f39b2b7ee0fe9e28decf5a702f3c3e0914f2cb79 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Tue, 27 Jan 2026 12:14:21 +0100 Subject: [PATCH 2/6] fix: write valid hook configs Issue: architect hook gemini wrote literal \n in JSON and architect notify did not light up. Solution: write real newlines in hook installer output and notify payloads. Solution: document restarting terminals after upgrades for refreshed helper scripts. --- docs/ai-integration.md | 2 ++ src/shell.zig | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/ai-integration.md b/docs/ai-integration.md index 828e0a3..7d205e5 100644 --- a/docs/ai-integration.md +++ b/docs/ai-integration.md @@ -39,6 +39,8 @@ architect hook codex architect hook gemini ``` +If you upgrade Architect, restart existing terminals so the bundled `architect` script refreshes. + ## Claude Code Hooks 1. (Optional) Copy the helper script if the hook runs outside Architect: diff --git a/src/shell.zig b/src/shell.zig index f5f5ac2..57d5b52 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -81,7 +81,7 @@ const architect_command_script = \\ message = json.dumps({ \\ "session": int(session_id), \\ "state": state - \\ }) + "\\n" + \\ }) + "\n" \\ \\ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) \\ sock.connect(sock_path) @@ -166,7 +166,7 @@ const architect_command_script = \\ return None \\ \\def write_json(path: str, data: dict) -> None: - \\ write_text(path, json.dumps(data, indent=2, sort_keys=False) + "\\n") + \\ write_text(path, json.dumps(data, indent=2, sort_keys=False) + "\n") \\ \\def command_has_needle(command: str, needles: tuple[str, ...]) -> bool: \\ return any(needle in command for needle in needles) @@ -297,11 +297,11 @@ const architect_command_script = \\ for i, existing in enumerate(lines): \\ if existing.strip().startswith("notify"): \\ lines[i] = line - \\ return "\\n".join(lines) + "\\n" + \\ return "\n".join(lines) + "\n" \\ if lines and lines[-1].strip() != "": \\ lines.append("") \\ lines.append(line) - \\ return "\\n".join(lines) + "\\n" + \\ return "\n".join(lines) + "\n" \\ \\def install_claude() -> int: \\ path = os.path.expanduser("~/.claude/settings.json") From f5f85fc983d5f97e28b3a8e01bff2860c68536be Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Tue, 27 Jan 2026 12:21:31 +0100 Subject: [PATCH 3/6] fix: tighten hook installer exceptions Issue: Review comments requested narrowing exception handling in embedded installer script. Solution: Catch ImportError for tomllib import, JSONDecodeError for JSON parsing, and TOMLDecodeError for tomllib parsing. --- src/shell.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shell.zig b/src/shell.zig index 57d5b52..574df87 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -55,7 +55,7 @@ const architect_command_script = \\ \\try: \\ import tomllib - \\except Exception: + \\except ImportError: \\ tomllib = None \\ \\VALID_STATES = {"start", "awaiting_approval", "done"} @@ -100,7 +100,7 @@ const architect_command_script = \\ \\ try: \\ payload = json.loads(raw) - \\ except Exception: + \\ except json.JSONDecodeError: \\ return None \\ \\ if not isinstance(payload, dict): @@ -162,7 +162,7 @@ const architect_command_script = \\ return None \\ try: \\ return json.loads(text) - \\ except Exception: + \\ except json.JSONDecodeError: \\ return None \\ \\def write_json(path: str, data: dict) -> None: @@ -284,7 +284,7 @@ const architect_command_script = \\ for item in value: \\ if isinstance(item, str) and "architect_notify.py" in item: \\ return True - \\ except Exception: + \\ except tomllib.TOMLDecodeError: \\ pass \\ if 'notify = ["architect", "notify"]' in text: \\ return True From e85efcb0f872c3879e7799feba5547afc2ea1b3c Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Tue, 27 Jan 2026 12:28:39 +0100 Subject: [PATCH 4/6] fix: preserve codex notify with backups Issue: Users may already have Codex notify configured and need safe updates with backups. Solution: Add notify chaining via wrapper and timestamped backups for settings updates. --- README.md | 2 +- docs/ai-integration.md | 6 ++ src/shell.zig | 128 ++++++++++++++++++++++++++++++++++------- 3 files changed, 115 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f179e52..1ab4660 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Common settings include font family, theme colors, and grid font scale. The grid ## Documentation -* [`docs/ai-integration.md`](docs/ai-integration.md): set up Claude Code, Codex, and Gemini CLI hooks for status notifications (includes `architect notify` and `architect hook ...` inside Architect terminals). +* [`docs/ai-integration.md`](docs/ai-integration.md): set up Claude Code, Codex, and Gemini CLI hooks for status notifications (includes `architect notify`, `architect hook ...`, and timestamped backups). * [`docs/architecture.md`](docs/architecture.md): architecture overview and system boundaries. * [`docs/configuration.md`](docs/configuration.md): detailed configuration reference for `config.toml` and `persistence.toml`. * [`docs/development.md`](docs/development.md): build, test, and release process. diff --git a/docs/ai-integration.md b/docs/ai-integration.md index 7d205e5..afb1e47 100644 --- a/docs/ai-integration.md +++ b/docs/ai-integration.md @@ -40,6 +40,8 @@ architect hook gemini ``` If you upgrade Architect, restart existing terminals so the bundled `architect` script refreshes. +The installer writes timestamped backups before updating configs (for example: +`settings.json.architect.bak.20260127T153045Z`). ## Claude Code Hooks @@ -90,6 +92,10 @@ If you upgrade Architect, restart existing terminals so the bundled `architect` notify = ["architect", "notify"] ``` +If you already have `notify` configured, `architect hook codex` writes a wrapper +to `~/.codex/architect_notify_wrapper.py` and points `notify` to it so both the +existing notifier and Architect receive events. + ## Gemini CLI Hooks Gemini hooks must emit JSON to stdout, so keep using the wrapper script even inside diff --git a/src/shell.zig b/src/shell.zig index 574df87..c8087e5 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -48,8 +48,10 @@ const architect_command_script = \\ architect notify (reads stdin) \\ architect hook claude|codex|gemini \\""" + \\import datetime \\import json \\import os + \\import shutil \\import socket \\import sys \\ @@ -69,6 +71,8 @@ const architect_command_script = \\GEMINI_NEEDLES = ("architect_hook.py", "architect notify") \\ \\CODEX_NOTIFY = ["architect", "notify"] + \\CODEX_WRAPPER_PATH = os.path.expanduser("~/.codex/architect_notify_wrapper.py") + \\CODEX_WRAPPER_CMD = ["python3", CODEX_WRAPPER_PATH] \\ \\def notify_architect(state: str) -> None: \\ session_id = os.environ.get("ARCHITECT_SESSION_ID") @@ -145,6 +149,19 @@ const architect_command_script = \\ print(" architect notify (reads stdin)", file=sys.stderr) \\ print(" architect hook claude|codex|gemini", file=sys.stderr) \\ + \\def timestamp_suffix() -> str: + \\ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + \\ + \\def backup_path(path: str) -> str: + \\ return f"{path}.architect.bak.{timestamp_suffix()}" + \\ + \\def backup_file(path: str) -> None: + \\ try: + \\ if os.path.exists(path): + \\ shutil.copy2(path, backup_path(path)) + \\ except OSError: + \\ pass + \\ \\def read_text(path: str) -> str | None: \\ try: \\ with open(path, "r", encoding="utf-8") as handle: @@ -186,6 +203,83 @@ const architect_command_script = \\ return True \\ return False \\ + \\def normalize_notify(value) -> list[str] | None: + \\ if not isinstance(value, list): + \\ return None + \\ if not all(isinstance(item, str) for item in value): + \\ return None + \\ return value + \\ + \\def is_architect_notify(command: list[str]) -> bool: + \\ if command == CODEX_NOTIFY: + \\ return True + \\ if command == CODEX_WRAPPER_CMD: + \\ return True + \\ for item in command: + \\ if "architect_notify.py" in item: + \\ return True + \\ return False + \\ + \\def parse_notify_from_text(text: str) -> list[str] | None: + \\ for line in text.splitlines(): + \\ stripped = line.strip() + \\ if not stripped or stripped.startswith("#"): + \\ continue + \\ if stripped.startswith("notify"): + \\ parts = stripped.split("=", 1) + \\ if len(parts) != 2: + \\ return None + \\ value = parts[1].split("#", 1)[0].strip() + \\ if not value.startswith("[") or not value.endswith("]"): + \\ return None + \\ try: + \\ parsed = json.loads(value) + \\ except json.JSONDecodeError: + \\ return None + \\ return normalize_notify(parsed) + \\ return None + \\ + \\def read_codex_notify(text: str) -> list[str] | None: + \\ if tomllib is not None: + \\ try: + \\ data = tomllib.loads(text) + \\ value = data.get("notify") + \\ parsed = normalize_notify(value) + \\ if parsed is not None: + \\ return parsed + \\ except tomllib.TOMLDecodeError: + \\ return None + \\ return parse_notify_from_text(text) + \\ + \\def write_codex_wrapper(path: str, original: list[str]) -> None: + \\ content = \"\"\"#!/usr/bin/env python3 + \\import subprocess + \\import sys + \\ + \\ORIGINAL = {original} + \\ + \\def run(cmd: list[str], payload: str) -> None: + \\ try: + \\ subprocess.run(cmd + [payload], check=False, capture_output=True) + \\ except OSError: + \\ pass + \\ + \\def main() -> int: + \\ if len(sys.argv) < 2: + \\ return 0 + \\ payload = sys.argv[1] + \\ run(ORIGINAL, payload) + \\ run([\"architect\", \"notify\"], payload) + \\ return 0 + \\ + \\if __name__ == \"__main__\": + \\ raise SystemExit(main()) + \\\"\"\".format(original=repr(original)) + \\ dir_path = os.path.dirname(path) + \\ if dir_path: + \\ os.makedirs(dir_path, exist_ok=True) + \\ write_text(path, content) + \\ \\def ensure_group(groups): \\ if not groups: \\ group = {"hooks": []} @@ -273,24 +367,6 @@ const architect_command_script = \\ \\ return changed \\ - \\def codex_notify_installed(text: str) -> bool: - \\ if tomllib is not None: - \\ try: - \\ data = tomllib.loads(text) - \\ value = data.get("notify") - \\ if isinstance(value, list): - \\ if value == CODEX_NOTIFY: - \\ return True - \\ for item in value: - \\ if isinstance(item, str) and "architect_notify.py" in item: - \\ return True - \\ except tomllib.TOMLDecodeError: - \\ pass - \\ if 'notify = ["architect", "notify"]' in text: - \\ return True - \\ if "architect_notify.py" in text: - \\ return True - \\ return False \\ \\def upsert_notify_line(text: str, line: str) -> str: \\ lines = text.splitlines() @@ -310,6 +386,7 @@ const architect_command_script = \\ print(f"Failed to read {path}", file=sys.stderr) \\ return 1 \\ if ensure_claude_hooks(data): + \\ backup_file(path) \\ write_json(path, data) \\ print("Installed Claude hooks.") \\ else: @@ -323,6 +400,7 @@ const architect_command_script = \\ print(f"Failed to read {path}", file=sys.stderr) \\ return 1 \\ if ensure_gemini_hooks(data): + \\ backup_file(path) \\ write_json(path, data) \\ print("Installed Gemini hooks.") \\ else: @@ -335,11 +413,21 @@ const architect_command_script = \\ if text is None: \\ print(f"Failed to read {path}", file=sys.stderr) \\ return 1 - \\ if codex_notify_installed(text): + \\ existing = read_codex_notify(text) + \\ if existing is not None and is_architect_notify(existing): \\ print("Codex hooks already installed.") \\ return 0 - \\ notify_line = 'notify = ["architect", "notify"]' + \\ if existing is not None: + \\ write_codex_wrapper(CODEX_WRAPPER_PATH, existing) + \\ notify_line = f"notify = {json.dumps(CODEX_WRAPPER_CMD)}" + \\ new_text = upsert_notify_line(text, notify_line) + \\ backup_file(path) + \\ write_text(path, new_text) + \\ print("Installed Codex hooks (chained existing notify).") + \\ return 0 + \\ notify_line = f"notify = {json.dumps(CODEX_NOTIFY)}" \\ new_text = upsert_notify_line(text, notify_line) + \\ backup_file(path) \\ write_text(path, new_text) \\ print("Installed Codex hooks.") \\ return 0 From 97b592e35e8050dc56dc65a1804b13d44b900415 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Tue, 27 Jan 2026 12:33:28 +0100 Subject: [PATCH 5/6] fix: warn on codex notify overwrite Issue: Users asked to replace Codex notify while warning and printing backup names. Solution: Always overwrite notify, emit warning on existing config, and print timestamped backup path. --- docs/ai-integration.md | 5 ++- src/shell.zig | 73 +++++++++++++++--------------------------- 2 files changed, 28 insertions(+), 50 deletions(-) diff --git a/docs/ai-integration.md b/docs/ai-integration.md index afb1e47..b293d9d 100644 --- a/docs/ai-integration.md +++ b/docs/ai-integration.md @@ -92,9 +92,8 @@ The installer writes timestamped backups before updating configs (for example: notify = ["architect", "notify"] ``` -If you already have `notify` configured, `architect hook codex` writes a wrapper -to `~/.codex/architect_notify_wrapper.py` and points `notify` to it so both the -existing notifier and Architect receive events. +If you already have `notify` configured, `architect hook codex` overwrites it, +prints a warning, and prints the backup file name. ## Gemini CLI Hooks diff --git a/src/shell.zig b/src/shell.zig index c8087e5..66a7392 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -71,8 +71,6 @@ const architect_command_script = \\GEMINI_NEEDLES = ("architect_hook.py", "architect notify") \\ \\CODEX_NOTIFY = ["architect", "notify"] - \\CODEX_WRAPPER_PATH = os.path.expanduser("~/.codex/architect_notify_wrapper.py") - \\CODEX_WRAPPER_CMD = ["python3", CODEX_WRAPPER_PATH] \\ \\def notify_architect(state: str) -> None: \\ session_id = os.environ.get("ARCHITECT_SESSION_ID") @@ -155,12 +153,15 @@ const architect_command_script = \\def backup_path(path: str) -> str: \\ return f"{path}.architect.bak.{timestamp_suffix()}" \\ - \\def backup_file(path: str) -> None: + \\def backup_file(path: str) -> str | None: \\ try: \\ if os.path.exists(path): - \\ shutil.copy2(path, backup_path(path)) + \\ backup = backup_path(path) + \\ shutil.copy2(path, backup) + \\ return backup \\ except OSError: - \\ pass + \\ return None + \\ return None \\ \\def read_text(path: str) -> str | None: \\ try: @@ -213,8 +214,6 @@ const architect_command_script = \\def is_architect_notify(command: list[str]) -> bool: \\ if command == CODEX_NOTIFY: \\ return True - \\ if command == CODEX_WRAPPER_CMD: - \\ return True \\ for item in command: \\ if "architect_notify.py" in item: \\ return True @@ -239,6 +238,15 @@ const architect_command_script = \\ return normalize_notify(parsed) \\ return None \\ + \\def has_notify_line(text: str) -> bool: + \\ for line in text.splitlines(): + \\ stripped = line.strip() + \\ if not stripped or stripped.startswith("#"): + \\ continue + \\ if stripped.startswith("notify"): + \\ return True + \\ return False + \\ \\def read_codex_notify(text: str) -> list[str] | None: \\ if tomllib is not None: \\ try: @@ -251,35 +259,6 @@ const architect_command_script = \\ return None \\ return parse_notify_from_text(text) \\ - \\def write_codex_wrapper(path: str, original: list[str]) -> None: - \\ content = \"\"\"#!/usr/bin/env python3 - \\import subprocess - \\import sys - \\ - \\ORIGINAL = {original} - \\ - \\def run(cmd: list[str], payload: str) -> None: - \\ try: - \\ subprocess.run(cmd + [payload], check=False, capture_output=True) - \\ except OSError: - \\ pass - \\ - \\def main() -> int: - \\ if len(sys.argv) < 2: - \\ return 0 - \\ payload = sys.argv[1] - \\ run(ORIGINAL, payload) - \\ run([\"architect\", \"notify\"], payload) - \\ return 0 - \\ - \\if __name__ == \"__main__\": - \\ raise SystemExit(main()) - \\\"\"\".format(original=repr(original)) - \\ dir_path = os.path.dirname(path) - \\ if dir_path: - \\ os.makedirs(dir_path, exist_ok=True) - \\ write_text(path, content) - \\ \\def ensure_group(groups): \\ if not groups: \\ group = {"hooks": []} @@ -386,7 +365,9 @@ const architect_command_script = \\ print(f"Failed to read {path}", file=sys.stderr) \\ return 1 \\ if ensure_claude_hooks(data): - \\ backup_file(path) + \\ backup = backup_file(path) + \\ if backup: + \\ print(f"Wrote backup to {backup}") \\ write_json(path, data) \\ print("Installed Claude hooks.") \\ else: @@ -400,7 +381,9 @@ const architect_command_script = \\ print(f"Failed to read {path}", file=sys.stderr) \\ return 1 \\ if ensure_gemini_hooks(data): - \\ backup_file(path) + \\ backup = backup_file(path) + \\ if backup: + \\ print(f"Wrote backup to {backup}") \\ write_json(path, data) \\ print("Installed Gemini hooks.") \\ else: @@ -417,17 +400,13 @@ const architect_command_script = \\ if existing is not None and is_architect_notify(existing): \\ print("Codex hooks already installed.") \\ return 0 - \\ if existing is not None: - \\ write_codex_wrapper(CODEX_WRAPPER_PATH, existing) - \\ notify_line = f"notify = {json.dumps(CODEX_WRAPPER_CMD)}" - \\ new_text = upsert_notify_line(text, notify_line) - \\ backup_file(path) - \\ write_text(path, new_text) - \\ print("Installed Codex hooks (chained existing notify).") - \\ return 0 + \\ if existing is not None or has_notify_line(text): + \\ print("Warning: replacing existing Codex notify configuration.", file=sys.stderr) \\ notify_line = f"notify = {json.dumps(CODEX_NOTIFY)}" \\ new_text = upsert_notify_line(text, notify_line) - \\ backup_file(path) + \\ backup = backup_file(path) + \\ if backup: + \\ print(f"Wrote backup to {backup}") \\ write_text(path, new_text) \\ print("Installed Codex hooks.") \\ return 0 From e9ee07a17baf578d5601ca323d2e75fb45db744c Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Tue, 27 Jan 2026 13:04:32 +0100 Subject: [PATCH 6/6] fix: write codex notify at root Issue: Codex notify line was appended under the last table instead of root. Solution: Remove existing notify lines and insert the new notify before the first table header. --- src/shell.zig | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/shell.zig b/src/shell.zig index 66a7392..2efe08a 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -349,13 +349,27 @@ const architect_command_script = \\ \\def upsert_notify_line(text: str, line: str) -> str: \\ lines = text.splitlines() + \\ filtered = [] + \\ for existing in lines: + \\ stripped = existing.strip() + \\ if stripped.startswith("notify") and not stripped.startswith("#"): + \\ continue + \\ filtered.append(existing) + \\ lines = filtered + \\ insert_idx = None \\ for i, existing in enumerate(lines): - \\ if existing.strip().startswith("notify"): - \\ lines[i] = line - \\ return "\n".join(lines) + "\n" - \\ if lines and lines[-1].strip() != "": - \\ lines.append("") - \\ lines.append(line) + \\ stripped = existing.strip() + \\ if not stripped or stripped.startswith("#"): + \\ continue + \\ if stripped.startswith("["): + \\ insert_idx = i + \\ break + \\ if insert_idx is None: + \\ if lines and lines[-1].strip() != "": + \\ lines.append("") + \\ lines.append(line) + \\ else: + \\ lines.insert(insert_idx, line) \\ return "\n".join(lines) + "\n" \\ \\def install_claude() -> int: