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.
- hermes_gate/__init__.py +3 -0
- hermes_gate/adapters.py +299 -0
- hermes_gate/classification.py +37 -0
- hermes_gate/cli.py +115 -0
- hermes_gate/codex_install.py +212 -0
- hermes_gate/command_detection.py +206 -0
- hermes_gate/config.py +198 -0
- hermes_gate/doctor.py +102 -0
- hermes_gate/engine.py +515 -0
- hermes_gate/execution.py +102 -0
- hermes_gate/gate_result.py +141 -0
- hermes_gate/gitstate.py +153 -0
- hermes_gate/hooks.py +153 -0
- hermes_gate/init_repo.py +321 -0
- hermes_gate/providers.py +138 -0
- hermes_gate/receipts.py +94 -0
- hermes_gate/repo_runner.py +254 -0
- hermes_gate/schemas/__init__.py +1 -0
- hermes_gate/schemas/gate-result-v1.schema.json +184 -0
- hermes_gate/status.py +24 -0
- hermes_gate-0.1.2.dist-info/METADATA +175 -0
- hermes_gate-0.1.2.dist-info/RECORD +26 -0
- hermes_gate-0.1.2.dist-info/WHEEL +5 -0
- hermes_gate-0.1.2.dist-info/entry_points.txt +2 -0
- hermes_gate-0.1.2.dist-info/licenses/LICENSE +190 -0
- hermes_gate-0.1.2.dist-info/top_level.txt +1 -0
hermes_gate/__init__.py
ADDED
hermes_gate/adapters.py
ADDED
|
@@ -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)
|