hermes-gate 0.1.2__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.
@@ -0,0 +1,3 @@
1
+ """Hermes Gate public package surface."""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,299 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import hashlib
5
+ import os
6
+ import re
7
+ import shutil
8
+ import stat
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .config import AdapterSpec
15
+ from .execution import run_argv
16
+ from .gate_result import validate_gate_result
17
+ from .gitstate import git_dir
18
+
19
+ _MINIMUMS = {
20
+ "pygate": (0, 2, 0),
21
+ "quick-gate": (0, 2, 3),
22
+ }
23
+ _SNAPSHOT_SKIP_DIRS = {".git", ".pygate", "__pycache__", ".venv", "venv", "node_modules"}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ResolvedAdapter:
28
+ name: str
29
+ argv: tuple[str, ...]
30
+ version: str
31
+
32
+
33
+ def resolve_adapter(
34
+ spec: AdapterSpec, root: Path, *, timeout_seconds: float = 5
35
+ ) -> tuple[ResolvedAdapter | None, str]:
36
+ if not spec.enabled:
37
+ return None, "native repository commands"
38
+ floor = _MINIMUMS.get(spec.name)
39
+ if floor is None:
40
+ return None, f"unsupported adapter: {spec.name}"
41
+ if not spec.argv:
42
+ return None, f"{spec.name} adapter argv is empty"
43
+ executable = spec.argv[0]
44
+ executable_path = Path(executable)
45
+ if not executable_path.is_absolute():
46
+ executable_path = root / executable_path
47
+ if shutil.which(executable) is None and not (
48
+ executable_path.is_file() and os.access(executable_path, os.X_OK)
49
+ ):
50
+ return None, f"{spec.name} adapter executable is unavailable"
51
+ version_run = run_argv(
52
+ [*spec.argv, "--version"],
53
+ cwd=root,
54
+ timeout_seconds=max(timeout_seconds, 0.001),
55
+ )
56
+ if version_run.unavailable:
57
+ return None, f"{spec.name} adapter executable is unavailable"
58
+ if version_run.timed_out:
59
+ return None, f"{spec.name} adapter version probe timed out"
60
+ version_text = (version_run.stdout or version_run.stderr).strip().splitlines()
61
+ version = version_text[0] if version_text else ""
62
+ parsed = _version_tuple(version)
63
+ configured = _version_tuple(spec.minimum_version) if spec.minimum_version else floor
64
+ if configured is None:
65
+ return None, f"{spec.name} adapter minimum_version is invalid: {spec.minimum_version}"
66
+ minimum = max(floor, configured)
67
+ if parsed is None or parsed < minimum:
68
+ required = ".".join(str(item) for item in minimum)
69
+ return None, f"{spec.name} {required}+ required; found {version or 'unknown'}"
70
+ return ResolvedAdapter(spec.name, spec.argv, version), ""
71
+
72
+
73
+ def run_adapter(
74
+ spec: AdapterSpec,
75
+ *,
76
+ root: Path,
77
+ mode: str,
78
+ checked_paths: list[str],
79
+ timeout_seconds: float,
80
+ ) -> dict[str, Any]:
81
+ started = time.monotonic()
82
+ adapter, reason = resolve_adapter(spec, root, timeout_seconds=timeout_seconds)
83
+ if adapter is None:
84
+ return {"status": "ERROR", "reason": reason, "checks": [], "command_versions": {}}
85
+ remaining = timeout_seconds - (time.monotonic() - started)
86
+ if remaining <= 0:
87
+ return {
88
+ "status": "ERROR",
89
+ "reason": f"{adapter.name} adapter budget exhausted during version probe",
90
+ "checks": [],
91
+ "command_versions": {adapter.name: adapter.version},
92
+ }
93
+ state_dir = git_dir(root) / "hermes-gate" / "adapters" / adapter.name / mode
94
+ state_dir.mkdir(parents=True, exist_ok=True)
95
+ expected_digest, expected_paths = canonical_snapshot_digest(root, checked_paths)
96
+ changed_file = state_dir / "changed-files.txt"
97
+ changed_file.write_text(
98
+ "".join(f"{item}\n" for item in checked_paths),
99
+ encoding="utf-8",
100
+ )
101
+ primitive_mode = "canary" if adapter.name == "pygate" and mode == "fast" else mode
102
+ if adapter.name == "quick-gate" and mode == "fast":
103
+ primitive_mode = "quick"
104
+ argv = [
105
+ *adapter.argv,
106
+ "run",
107
+ "--mode",
108
+ primitive_mode,
109
+ "--changed-files",
110
+ str(changed_file),
111
+ "--output-dir",
112
+ str(state_dir),
113
+ ]
114
+ result_path = state_dir / "gate-result.json"
115
+ result_path.unlink(missing_ok=True)
116
+ execution = run_argv(argv, cwd=root, timeout_seconds=remaining)
117
+ if execution.unavailable or execution.timed_out:
118
+ detail = "executable unavailable" if execution.unavailable else "timed out"
119
+ return {
120
+ "status": "ERROR",
121
+ "reason": f"{adapter.name} adapter {detail}",
122
+ "checks": [],
123
+ "command_versions": {adapter.name: adapter.version},
124
+ }
125
+ try:
126
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
127
+ except (FileNotFoundError, json.JSONDecodeError, OSError) as exc:
128
+ return {
129
+ "status": "ERROR",
130
+ "reason": f"{adapter.name} did not produce a readable gate-result/v1: {exc}",
131
+ "checks": [],
132
+ "command_versions": {adapter.name: adapter.version},
133
+ }
134
+ errors = validate_gate_result(payload)
135
+ if errors:
136
+ return {
137
+ "status": "ERROR",
138
+ "reason": f"{adapter.name} emitted invalid gate-result/v1: {'; '.join(errors)}",
139
+ "checks": [],
140
+ "command_versions": {adapter.name: adapter.version},
141
+ }
142
+ missing_paths = sorted(set(expected_paths) - set(payload["checked_paths"]))
143
+ if missing_paths:
144
+ return {
145
+ "status": "ERROR",
146
+ "reason": f"{adapter.name} omitted checked paths: {', '.join(missing_paths)}",
147
+ "checks": [],
148
+ "command_versions": {adapter.name: adapter.version},
149
+ }
150
+ current_digest, _ = canonical_snapshot_digest(root, checked_paths)
151
+ if current_digest != expected_digest:
152
+ return {
153
+ "status": "ERROR",
154
+ "reason": f"{adapter.name} checked inputs changed during execution",
155
+ "checks": [],
156
+ "command_versions": {adapter.name: adapter.version},
157
+ }
158
+ if payload["snapshot_digest"] != expected_digest:
159
+ return {
160
+ "status": "ERROR",
161
+ "reason": f"{adapter.name} snapshot digest does not match checked inputs",
162
+ "checks": [],
163
+ "command_versions": {adapter.name: adapter.version},
164
+ }
165
+ if payload["status"] == "pass" and execution.returncode != 0:
166
+ return {
167
+ "status": "ERROR",
168
+ "reason": f"{adapter.name} emitted pass but exited with {execution.returncode}",
169
+ "checks": [],
170
+ "command_versions": {adapter.name: adapter.version},
171
+ }
172
+ blocking_checks = [
173
+ str(check.get("name", "unknown"))
174
+ for check in payload["checks"]
175
+ if check.get("status") not in {"pass", "skipped"}
176
+ ]
177
+ if payload["status"] == "pass" and (
178
+ blocking_checks or payload.get("errors") or payload.get("findings")
179
+ ):
180
+ detail = ", ".join(blocking_checks) if blocking_checks else "errors or findings"
181
+ return {
182
+ "status": "ERROR",
183
+ "reason": f"{adapter.name} emitted pass with blocking evidence: {detail}",
184
+ "checks": list(payload["checks"]),
185
+ "command_versions": {adapter.name: adapter.version},
186
+ }
187
+ status = "PASS" if payload["status"] == "pass" else "FAIL"
188
+ if payload["status"] == "error":
189
+ status = "ERROR"
190
+ reason = "" if status == "PASS" else f"{adapter.name} returned {payload['status']}"
191
+ versions = {adapter.name: adapter.version}
192
+ for name, value in payload.get("command_versions", {}).items():
193
+ versions[str(name)] = value if isinstance(value, str) else json.dumps(value, sort_keys=True)
194
+ return {
195
+ "status": status,
196
+ "reason": reason,
197
+ "checks": list(payload["checks"]),
198
+ "command_versions": versions,
199
+ "adapter": adapter.name,
200
+ "adapter_exit_code": execution.returncode,
201
+ "gate_result": payload,
202
+ }
203
+
204
+
205
+ def _version_tuple(value: str) -> tuple[int, int, int] | None:
206
+ match = re.search(r"(?<!\d)(\d+)\.(\d+)\.(\d+)(?!\d)", value)
207
+ if not match:
208
+ return None
209
+ return tuple(int(item) for item in match.groups())
210
+
211
+
212
+ def canonical_snapshot_digest(root: Path, checked_paths: list[str]) -> tuple[str, list[str]]:
213
+ records: list[dict[str, Any]] = []
214
+ normalized: set[str] = set()
215
+ for raw_path in checked_paths:
216
+ path = Path(raw_path)
217
+ if not path.is_absolute():
218
+ path = root / path
219
+ files = _files_for_snapshot(path)
220
+ if not files and not path.is_dir():
221
+ files = [path]
222
+ for file_path in files:
223
+ relative = _relative_snapshot_path(file_path, root)
224
+ if relative in normalized:
225
+ continue
226
+ normalized.add(relative)
227
+ if file_path.is_symlink():
228
+ record: dict[str, Any] = {
229
+ "path": relative,
230
+ "exists": True,
231
+ "symlink": os.readlink(file_path),
232
+ }
233
+ try:
234
+ target_stat = file_path.stat()
235
+ except FileNotFoundError:
236
+ record["target_type"] = "missing"
237
+ except OSError:
238
+ record["target_type"] = "unreadable"
239
+ else:
240
+ record["target_type"] = (
241
+ "file"
242
+ if stat.S_ISREG(target_stat.st_mode)
243
+ else "directory"
244
+ if stat.S_ISDIR(target_stat.st_mode)
245
+ else "other"
246
+ )
247
+ if record.get("target_type") == "file":
248
+ try:
249
+ content = file_path.read_bytes()
250
+ except OSError:
251
+ record["target_type"] = "unreadable"
252
+ else:
253
+ record.update(
254
+ {"size": len(content), "sha256": hashlib.sha256(content).hexdigest()}
255
+ )
256
+ records.append(record)
257
+ continue
258
+ if not file_path.exists() or not file_path.is_file():
259
+ records.append({"path": relative, "exists": False})
260
+ continue
261
+ try:
262
+ data = file_path.read_bytes()
263
+ except OSError as exc:
264
+ records.append({"path": relative, "exists": False, "error": type(exc).__name__})
265
+ continue
266
+ records.append(
267
+ {
268
+ "path": relative,
269
+ "exists": True,
270
+ "size": len(data),
271
+ "sha256": hashlib.sha256(data).hexdigest(),
272
+ }
273
+ )
274
+ records.sort(key=lambda record: record["path"])
275
+ encoded = json.dumps(records, sort_keys=True, separators=(",", ":")).encode("utf-8")
276
+ return hashlib.sha256(encoded).hexdigest(), [str(record["path"]) for record in records]
277
+
278
+
279
+ def _files_for_snapshot(path: Path) -> list[Path]:
280
+ if path.is_file() or path.is_symlink():
281
+ return [path]
282
+ if not path.is_dir():
283
+ return [path]
284
+ files: list[Path] = []
285
+ for current, directories, names in os.walk(path, followlinks=False):
286
+ directories[:] = sorted(
287
+ item
288
+ for item in directories
289
+ if item not in _SNAPSHOT_SKIP_DIRS and not item.startswith(".")
290
+ )
291
+ files.extend(Path(current) / name for name in sorted(names))
292
+ return files
293
+
294
+
295
+ def _relative_snapshot_path(path: Path, root: Path) -> str:
296
+ try:
297
+ return path.absolute().relative_to(root.resolve()).as_posix()
298
+ except ValueError:
299
+ return path.absolute().as_posix()
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ CODE_EXTENSIONS = {
7
+ ".c",
8
+ ".cc",
9
+ ".cpp",
10
+ ".cs",
11
+ ".go",
12
+ ".h",
13
+ ".hpp",
14
+ ".java",
15
+ ".js",
16
+ ".jsx",
17
+ ".kt",
18
+ ".kts",
19
+ ".php",
20
+ ".py",
21
+ ".rb",
22
+ ".rs",
23
+ ".sh",
24
+ ".swift",
25
+ ".ts",
26
+ ".tsx",
27
+ ".vue",
28
+ ".yaml",
29
+ ".yml",
30
+ ".toml",
31
+ }
32
+ CODE_NAMES = {"Dockerfile", "Makefile", "Justfile", "AGENTS.md", "CLAUDE.md"}
33
+
34
+
35
+ def is_code_path(path: str) -> bool:
36
+ item = Path(path)
37
+ return item.suffix.lower() in CODE_EXTENSIONS or item.name in CODE_NAMES
hermes_gate/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from .codex_install import install as install_codex
11
+ from .codex_install import uninstall as uninstall_codex
12
+ from .doctor import diagnose
13
+ from .engine import boundary, fast, full, repair, review
14
+ from .gitstate import repo_root
15
+ from .hooks import read_stdin_payload, run_hook
16
+ from .init_repo import initialize, uninstall
17
+ from .status import EXIT_CODES, Status
18
+
19
+
20
+ def parser() -> argparse.ArgumentParser:
21
+ root = argparse.ArgumentParser(
22
+ prog="hermes-gate", description="Bounded session-wide completion rail"
23
+ )
24
+ root.add_argument("--version", action="version", version="hermes-gate 0.1.2")
25
+ sub = root.add_subparsers(dest="command", required=True)
26
+ init = sub.add_parser(
27
+ "init", help="install a repository profile, runner, workflow, and baseline"
28
+ )
29
+ init.add_argument("--force", action="store_true")
30
+ sub.add_parser("fast", help="run the cached session-scoped deterministic gate")
31
+ sub.add_parser("repair", help="run one configured deterministic repair")
32
+ sub.add_parser("review", help="run one bounded independent review")
33
+ sub.add_parser("full", help="run the complete declared repository contract")
34
+ bound = sub.add_parser("boundary", help="validate exact receipts for a Git or PR boundary")
35
+ bound.add_argument("action", choices=("commit", "push", "pr-create", "pr-ready"))
36
+ sub.add_parser("doctor", help="read-only installation and receipt diagnostics")
37
+ sub.add_parser("install-codex", help=argparse.SUPPRESS)
38
+ sub.add_parser("uninstall-codex", help=argparse.SUPPRESS)
39
+ sub.add_parser("uninstall-repo", help=argparse.SUPPRESS)
40
+ hook = sub.add_parser("hook", help=argparse.SUPPRESS)
41
+ hook.add_argument("event", choices=("session-start", "stop", "pre-tool-use"))
42
+ return root
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ args = parser().parse_args(argv)
47
+ if args.command == "hook":
48
+ output = run_hook(args.event, read_stdin_payload())
49
+ print(json.dumps(output, sort_keys=True))
50
+ return 0
51
+ if args.command == "doctor":
52
+ output = diagnose(Path.cwd())
53
+ return _emit(output)
54
+ if args.command == "install-codex":
55
+ executable = Path(sys.argv[0])
56
+ output = install_codex(executable)
57
+ return _emit(output)
58
+ if args.command == "uninstall-codex":
59
+ return _emit(uninstall_codex())
60
+ root = repo_root(Path.cwd())
61
+ if root is None:
62
+ return _emit(
63
+ {
64
+ "schema": "hermes-gate/result-v1",
65
+ "command": args.command,
66
+ "status": Status.NOT_APPLICABLE.value,
67
+ "reason": "not a Git repository",
68
+ "elapsed_ms": 0,
69
+ }
70
+ )
71
+ routes: dict[str, Callable[[], dict[str, Any]]] = {
72
+ "init": lambda: _wrap_init(root, args.force),
73
+ "fast": lambda: fast(root),
74
+ "repair": lambda: repair(root),
75
+ "review": lambda: review(root),
76
+ "full": lambda: full(root),
77
+ "boundary": lambda: boundary(root, args.action),
78
+ "uninstall-repo": lambda: _wrap_uninstall(root),
79
+ }
80
+ return _emit(routes[args.command]())
81
+
82
+
83
+ def _wrap_init(root: Path, force: bool) -> dict[str, Any]:
84
+ started = time.monotonic()
85
+ value = initialize(root, force=force)
86
+ return {
87
+ "schema": "hermes-gate/result-v1",
88
+ "command": "init",
89
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
90
+ **value,
91
+ }
92
+
93
+
94
+ def _wrap_uninstall(root: Path) -> dict[str, Any]:
95
+ started = time.monotonic()
96
+ value = uninstall(root)
97
+ return {
98
+ "schema": "hermes-gate/result-v1",
99
+ "command": "uninstall-repo",
100
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
101
+ **value,
102
+ }
103
+
104
+
105
+ def _emit(output: dict[str, Any]) -> int:
106
+ print(json.dumps(output, indent=2, sort_keys=True))
107
+ try:
108
+ status = Status(str(output.get("status", Status.ERROR.value)))
109
+ except ValueError:
110
+ status = Status.ERROR
111
+ return EXIT_CODES[status]
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ MARKER_START = "<!-- hermes-gate:v0.1:start -->"
14
+ MARKER_END = "<!-- hermes-gate:v0.1:end -->"
15
+ CONTRACT = f"""
16
+ {MARKER_START}
17
+ ## Hermes Gate completion rail
18
+
19
+ For code changes, run `hermes-gate fast` before declaring completion and one `hermes-gate review`
20
+ for a completed diff. Run `hermes-gate full` for PR or release readiness when the profile requires
21
+ it. Matching receipts are mandatory at commit, push, and PR boundaries. Read-only, non-Git,
22
+ unchanged, and non-code work is exempt. Hooks never repair files or authorize public actions.
23
+ {MARKER_END}
24
+ """.strip()
25
+
26
+
27
+ def install(executable: Path, *, codex_home: Path | None = None) -> dict[str, Any]:
28
+ home = (codex_home or Path.home() / ".codex").resolve()
29
+ home.mkdir(parents=True, exist_ok=True)
30
+ hooks_path = home / "hooks.json"
31
+ agents_path = home / "AGENTS.md"
32
+ backup_dir = (
33
+ Path.home()
34
+ / ".local"
35
+ / "state"
36
+ / "hermes-gate"
37
+ / "codex-backups"
38
+ / datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
39
+ )
40
+ backup_dir.mkdir(parents=True, exist_ok=True)
41
+ for path in (hooks_path, agents_path):
42
+ if path.exists():
43
+ shutil.copy2(path, backup_dir / path.name)
44
+
45
+ hooks = _read_hooks(hooks_path)
46
+ command = str(executable.resolve())
47
+ for event in ("SessionStart", "Stop", "PreToolUse"):
48
+ groups = hooks["hooks"].setdefault(event, [])
49
+ hooks["hooks"][event] = [group for group in groups if not _is_ours(event, group)]
50
+ hooks["hooks"]["SessionStart"].append(
51
+ {
52
+ "matcher": "startup|resume|clear|compact",
53
+ "hooks": [
54
+ {
55
+ "type": "command",
56
+ "command": f'"{command}" hook session-start',
57
+ "timeout": 3,
58
+ "additionalContextLimit": 500,
59
+ }
60
+ ],
61
+ }
62
+ )
63
+ hooks["hooks"]["Stop"].append(
64
+ {"hooks": [{"type": "command", "command": f'"{command}" hook stop', "timeout": 10}]}
65
+ )
66
+ hooks["hooks"]["PreToolUse"].append(
67
+ {
68
+ "matcher": "^Bash$",
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "command": f'"{command}" hook pre-tool-use',
73
+ "timeout": 5,
74
+ "statusMessage": "Checking Hermes Gate receipts",
75
+ }
76
+ ],
77
+ }
78
+ )
79
+ _atomic_json(hooks_path, hooks)
80
+ original = agents_path.read_text(encoding="utf-8") if agents_path.exists() else ""
81
+ agents_path.write_text(_replace_contract(original), encoding="utf-8")
82
+ manifest = {
83
+ "schema": "hermes-gate/codex-install-v1",
84
+ "executable": command,
85
+ "hooks_path": str(hooks_path),
86
+ "hooks_sha256": hashlib.sha256(hooks_path.read_bytes()).hexdigest(),
87
+ "agents_path": str(agents_path),
88
+ "backup_dir": str(backup_dir),
89
+ "rollback": f'"{command}" uninstall-codex',
90
+ "trust_action": "Open /hooks in Codex and trust the three exact Hermes Gate command definitions.",
91
+ }
92
+ manifest_path = Path.home() / ".local" / "state" / "hermes-gate" / "codex-install.json"
93
+ _atomic_json(manifest_path, manifest)
94
+ return {"status": "PASS", **manifest}
95
+
96
+
97
+ def uninstall(*, codex_home: Path | None = None) -> dict[str, Any]:
98
+ home = (codex_home or Path.home() / ".codex").resolve()
99
+ hooks_path = home / "hooks.json"
100
+ agents_path = home / "AGENTS.md"
101
+ hooks = _read_hooks(hooks_path)
102
+ removed = 0
103
+ for event, groups in list(hooks.get("hooks", {}).items()):
104
+ kept = [group for group in groups if not _is_ours(event, group)]
105
+ removed += len(groups) - len(kept)
106
+ hooks["hooks"][event] = kept
107
+ _atomic_json(hooks_path, hooks)
108
+ if agents_path.exists():
109
+ agents_path.write_text(
110
+ _replace_contract(agents_path.read_text(encoding="utf-8"), remove=True),
111
+ encoding="utf-8",
112
+ )
113
+ return {"status": "PASS", "removed_hook_groups": removed, "preserved_unrelated_hooks": True}
114
+
115
+
116
+ def installed(*, codex_home: Path | None = None) -> dict[str, Any]:
117
+ home = (codex_home or Path.home() / ".codex").resolve()
118
+ hooks = _read_hooks(home / "hooks.json")
119
+ counts = {
120
+ event: sum(1 for group in hooks.get("hooks", {}).get(event, []) if _is_ours(event, group))
121
+ for event in ("SessionStart", "Stop", "PreToolUse")
122
+ }
123
+ agents = (
124
+ (home / "AGENTS.md").read_text(encoding="utf-8") if (home / "AGENTS.md").exists() else ""
125
+ )
126
+ return {
127
+ "hook_groups": counts,
128
+ "instruction": MARKER_START in agents,
129
+ "status": "PASS"
130
+ if all(value == 1 for value in counts.values()) and MARKER_START in agents
131
+ else "NOT_CONFIGURED",
132
+ }
133
+
134
+
135
+ def _read_hooks(path: Path) -> dict[str, Any]:
136
+ try:
137
+ raw = json.loads(path.read_text(encoding="utf-8"))
138
+ except FileNotFoundError:
139
+ return {"description": "User lifecycle hooks", "hooks": {}}
140
+ if not isinstance(raw, dict) or not isinstance(raw.get("hooks", {}), dict):
141
+ raise ValueError(f"malformed Codex hook file: {path}")
142
+ raw.setdefault("hooks", {})
143
+ return raw
144
+
145
+
146
+ def _is_ours(event: str, group: Any) -> bool:
147
+ """Recognize only the complete v0.1 hook definitions we install.
148
+
149
+ Command text is user-owned configuration, so a substring such as
150
+ ``hermes-gate`` is not an ownership marker: it can describe an unrelated
151
+ hook like ``hermes-gate-notify``. The complete event, matcher, hook shape,
152
+ and parsed command argv must match instead.
153
+ """
154
+ expected = {
155
+ "SessionStart": (
156
+ "startup|resume|clear|compact",
157
+ "session-start",
158
+ 3,
159
+ {"additionalContextLimit": 500},
160
+ ),
161
+ "Stop": (None, "stop", 10, {}),
162
+ "PreToolUse": (
163
+ "^Bash$",
164
+ "pre-tool-use",
165
+ 5,
166
+ {"statusMessage": "Checking Hermes Gate receipts"},
167
+ ),
168
+ }.get(event)
169
+ if expected is None or not isinstance(group, dict):
170
+ return False
171
+ matcher, action, timeout, extra = expected
172
+ if group.get("matcher") != matcher or set(group) != (
173
+ {"hooks"} if matcher is None else {"matcher", "hooks"}
174
+ ):
175
+ return False
176
+ hook_items = group.get("hooks")
177
+ if not isinstance(hook_items, list) or len(hook_items) != 1:
178
+ return False
179
+ hook = hook_items[0]
180
+ if (
181
+ not isinstance(hook, dict)
182
+ or hook.get("type") != "command"
183
+ or hook.get("timeout") != timeout
184
+ ):
185
+ return False
186
+ allowed_keys = {"type", "command", "timeout", *extra}
187
+ if set(hook) != allowed_keys or any(hook.get(key) != value for key, value in extra.items()):
188
+ return False
189
+ try:
190
+ argv = shlex.split(str(hook["command"]))
191
+ except (KeyError, TypeError, ValueError):
192
+ return False
193
+ return len(argv) == 3 and Path(argv[0]).name == "hermes-gate" and argv[1:] == ["hook", action]
194
+
195
+
196
+ def _replace_contract(text: str, *, remove: bool = False) -> str:
197
+ start = text.find(MARKER_START)
198
+ end = text.find(MARKER_END)
199
+ if start >= 0 and end >= start:
200
+ end += len(MARKER_END)
201
+ text = (text[:start].rstrip() + "\n" + text[end:].lstrip()).rstrip()
202
+ if remove:
203
+ return text + ("\n" if text else "")
204
+ return text.rstrip() + ("\n\n" if text.strip() else "") + CONTRACT + "\n"
205
+
206
+
207
+ def _atomic_json(path: Path, value: dict[str, Any]) -> None:
208
+ path.parent.mkdir(parents=True, exist_ok=True)
209
+ temporary = path.with_suffix(path.suffix + ".tmp")
210
+ temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
211
+ os.chmod(temporary, 0o600)
212
+ os.replace(temporary, path)