dos-kernel 0.22.0__py3-none-win_amd64.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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- dos_mcp/server.py +779 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""dos.drivers.export_statsd — the StatsD/DogStatsD occupant of `dos.exporter` (docs/266).
|
|
2
|
+
|
|
3
|
+
The second connector behind the verdict exporter, and the native METRICS path. Where
|
|
4
|
+
`export_file` re-emits the journal lines for a log shipper to parse, THIS driver turns
|
|
5
|
+
the verdict stream into COUNTERS — one increment per `(syscall, verdict)` pair — so a
|
|
6
|
+
time-series backend charts "liveness STALLED per minute" or "efficiency WASTEFUL events
|
|
7
|
+
per run" without a log-parsing rule in between. It speaks the StatsD line protocol over
|
|
8
|
+
UDP (the lingua franca Datadog's DogStatsD, Telegraf, Vector, and statsd_exporter all
|
|
9
|
+
ingest). It registers through the `dos.exporters` entry-point group, so
|
|
10
|
+
`resolve_exporter("statsd")` finds it by name and no kernel module imports it.
|
|
11
|
+
|
|
12
|
+
Why it ships in the core (no extra)
|
|
13
|
+
===================================
|
|
14
|
+
|
|
15
|
+
The StatsD protocol is a one-line UDP datagram — `stdlib socket` is all it needs, no
|
|
16
|
+
client library. So this driver adds NO dependency and ships in the core, the same as
|
|
17
|
+
`export_file` (only the OTLP driver pulls a real SDK, behind `[export-otlp]`).
|
|
18
|
+
|
|
19
|
+
The line it emits (the DogStatsD form, docs/266 §2)
|
|
20
|
+
===================================================
|
|
21
|
+
|
|
22
|
+
dos.verdict:1|c|#syscall:liveness,verdict:STALLED
|
|
23
|
+
|
|
24
|
+
One COUNTER (`|c|`) per distinct `(syscall, verdict)` in the batch, its value the count
|
|
25
|
+
of matching events, tagged `syscall:` + `verdict:` (DogStatsD `|#tag:val` extension —
|
|
26
|
+
what Datadog/Telegraf/Vector accept; a plain-StatsD collector ignores the tag suffix and
|
|
27
|
+
still counts the metric). Aggregating identical pairs into one datagram (rather than one
|
|
28
|
+
per event) keeps the wire traffic proportional to the verdict CARDINALITY, not the event
|
|
29
|
+
count — a fleet that emits 10k ADVANCING verdicts sends one `…:10000|c|…` line. The
|
|
30
|
+
`run_id`/`lane` are deliberately NOT tags: they are high-cardinality (one series per run
|
|
31
|
+
would explode a metrics backend), and the per-run history already lives in `dos observe
|
|
32
|
+
--run`. Metrics are for trends; the journal/`observe` is for drill-down.
|
|
33
|
+
|
|
34
|
+
Disciplines (inherited from the seam — the `export_file`/`notify_webhook` posture)
|
|
35
|
+
==================================================================================
|
|
36
|
+
|
|
37
|
+
* **Fail-soft.** `export` returns an `ExportResult`, never raises — an unroutable host,
|
|
38
|
+
a closed socket, a send error all degrade to `exported=0` with a one-line reason. UDP
|
|
39
|
+
is fire-and-forget, so a "successful" send only means the datagram left the host; that
|
|
40
|
+
is the strongest delivery guarantee StatsD offers and we report it honestly.
|
|
41
|
+
* **Advisory only.** It reads a batch → sends counters. It mutates no DOS state, takes
|
|
42
|
+
no lease, stops no run, adjudicates nothing.
|
|
43
|
+
|
|
44
|
+
Routing
|
|
45
|
+
=======
|
|
46
|
+
|
|
47
|
+
* **host**: explicit arg › `$DOS_STATSD_HOST` › `<root>/.env` › `127.0.0.1`.
|
|
48
|
+
* **port**: explicit arg › `$DOS_STATSD_PORT` › `<root>/.env` › `8125`.
|
|
49
|
+
* **prefix**: the metric name (default `dos.verdict`); override for a namespaced shop.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import os
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
|
|
57
|
+
from dos.exporter import ExportResult, _max_seq_cursor
|
|
58
|
+
|
|
59
|
+
_DEFAULT_HOST = "127.0.0.1"
|
|
60
|
+
_DEFAULT_PORT = 8125
|
|
61
|
+
_DEFAULT_PREFIX = "dos.verdict"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_env_file(root: Path) -> dict[str, str]:
|
|
65
|
+
"""Best-effort parse of `<root>/.env` → {KEY: value}. Never raises.
|
|
66
|
+
|
|
67
|
+
The `export_file._read_env_file` twin (same parser, different keys)."""
|
|
68
|
+
out: dict[str, str] = {}
|
|
69
|
+
try:
|
|
70
|
+
text = (root / ".env").read_text(encoding="utf-8")
|
|
71
|
+
except OSError:
|
|
72
|
+
return out
|
|
73
|
+
for line in text.splitlines():
|
|
74
|
+
line = line.strip()
|
|
75
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
76
|
+
continue
|
|
77
|
+
k, _, v = line.partition("=")
|
|
78
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_host(explicit: str | None, *, root: Path | None) -> str:
|
|
83
|
+
"""StatsD host: explicit arg › `$DOS_STATSD_HOST` › `<root>/.env` › 127.0.0.1."""
|
|
84
|
+
if explicit:
|
|
85
|
+
return explicit
|
|
86
|
+
env = os.environ.get("DOS_STATSD_HOST")
|
|
87
|
+
if env:
|
|
88
|
+
return env
|
|
89
|
+
if root is not None:
|
|
90
|
+
v = _read_env_file(root).get("DOS_STATSD_HOST")
|
|
91
|
+
if v:
|
|
92
|
+
return v
|
|
93
|
+
return _DEFAULT_HOST
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_port(explicit: int | None, *, root: Path | None) -> int:
|
|
97
|
+
"""StatsD port: explicit arg › `$DOS_STATSD_PORT` › `<root>/.env` › 8125.
|
|
98
|
+
|
|
99
|
+
A non-numeric override anywhere degrades to the default rather than raising."""
|
|
100
|
+
if explicit:
|
|
101
|
+
try:
|
|
102
|
+
return int(explicit)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return _DEFAULT_PORT
|
|
105
|
+
for raw in (os.environ.get("DOS_STATSD_PORT"),
|
|
106
|
+
(_read_env_file(root).get("DOS_STATSD_PORT") if root is not None else None)):
|
|
107
|
+
if raw:
|
|
108
|
+
try:
|
|
109
|
+
return int(raw)
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
continue
|
|
112
|
+
return _DEFAULT_PORT
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _sanitize_tag(value: str) -> str:
|
|
116
|
+
"""Make a tag value safe for the StatsD line: strip the metric delimiters.
|
|
117
|
+
|
|
118
|
+
`|`, `:`, `,`, `#`, and whitespace are the StatsD/DogStatsD line separators — a
|
|
119
|
+
verdict token or syscall name containing one (none of the kernel's closed sets do,
|
|
120
|
+
but a host driver's custom verdict might) would corrupt the datagram. We replace each
|
|
121
|
+
with `_` so a hostile/odd token degrades to a still-parseable tag, never a malformed
|
|
122
|
+
line (the byte-clean posture extended to the wire)."""
|
|
123
|
+
out = str(value)
|
|
124
|
+
for ch in ("|", ":", ",", "#", " ", "\n", "\t", "\r"):
|
|
125
|
+
out = out.replace(ch, "_")
|
|
126
|
+
return out or "none"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_lines(events, *, prefix: str = _DEFAULT_PREFIX) -> list[str]:
|
|
130
|
+
"""A batch of `VerdictEvent`s → the StatsD counter lines (pure; no I/O).
|
|
131
|
+
|
|
132
|
+
Aggregates identical `(syscall, verdict)` pairs into ONE counter line whose value is
|
|
133
|
+
the count, so wire traffic scales with verdict cardinality, not event count. Sorted
|
|
134
|
+
by (syscall, verdict) so the output is deterministic (golden-bytes testable). The
|
|
135
|
+
spine's analogue of `notify_webhook.build_payload` / `export_file`'s line builder —
|
|
136
|
+
kept pure and out of the kernel seam.
|
|
137
|
+
"""
|
|
138
|
+
counts: dict[tuple[str, str], int] = {}
|
|
139
|
+
for e in events:
|
|
140
|
+
key = (getattr(e, "syscall", "") or "none", getattr(e, "verdict", "") or "none")
|
|
141
|
+
counts[key] = counts.get(key, 0) + 1
|
|
142
|
+
lines: list[str] = []
|
|
143
|
+
for (syscall, verdict), n in sorted(counts.items()):
|
|
144
|
+
tags = f"syscall:{_sanitize_tag(syscall)},verdict:{_sanitize_tag(verdict)}"
|
|
145
|
+
lines.append(f"{prefix}:{n}|c|#{tags}")
|
|
146
|
+
return lines
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _UdpTransport:
|
|
150
|
+
"""The stdlib UDP send. Returns the byte count sent; raises on a socket error.
|
|
151
|
+
|
|
152
|
+
Kept behind a method so tests inject a fake with the same `send(host, port, lines)`
|
|
153
|
+
shape instead of patching socket (the `notify_webhook._UrllibTransport` posture).
|
|
154
|
+
One datagram per line (StatsD convention; a multi-metric datagram is an optimization
|
|
155
|
+
a real shop's local agent does, not us)."""
|
|
156
|
+
|
|
157
|
+
def send(self, host: str, port: int, lines: list[str]) -> int:
|
|
158
|
+
import socket
|
|
159
|
+
|
|
160
|
+
sent = 0
|
|
161
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
162
|
+
try:
|
|
163
|
+
for line in lines:
|
|
164
|
+
payload = line.encode("utf-8")
|
|
165
|
+
sock.sendto(payload, (host, int(port)))
|
|
166
|
+
sent += len(payload)
|
|
167
|
+
finally:
|
|
168
|
+
sock.close()
|
|
169
|
+
return sent
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class StatsdExporter:
|
|
173
|
+
"""Drain a batch of `VerdictEvent`s as StatsD counters over UDP.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
host:
|
|
178
|
+
StatsD/DogStatsD host; defaults to `$DOS_STATSD_HOST` / `.env` / 127.0.0.1.
|
|
179
|
+
port:
|
|
180
|
+
StatsD port; defaults to `$DOS_STATSD_PORT` / `.env` / 8125.
|
|
181
|
+
prefix:
|
|
182
|
+
The counter metric name (default `dos.verdict`).
|
|
183
|
+
root:
|
|
184
|
+
Workspace root for `.env` resolution (the `SubstrateConfig.root`).
|
|
185
|
+
dry_run:
|
|
186
|
+
Resolve + build the lines + report, send NOTHING.
|
|
187
|
+
transport:
|
|
188
|
+
Inject a fake with a `send(host, port, lines) -> int` method in tests; None uses
|
|
189
|
+
the stdlib UDP transport.
|
|
190
|
+
|
|
191
|
+
The constructor accepts-and-ignores the export CLI's `path`/`endpoint` superset
|
|
192
|
+
kwargs by NOT declaring them — `exporter._accepted_kwargs` filters the bag to the
|
|
193
|
+
params below, so a caller hands the same kwargs to any transport without branching.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
name = "statsd"
|
|
197
|
+
|
|
198
|
+
def __init__(self, *, host: str = "", port: int = 0, prefix: str = _DEFAULT_PREFIX,
|
|
199
|
+
root: "os.PathLike[str] | str | None" = None,
|
|
200
|
+
dry_run: bool = False, transport=None):
|
|
201
|
+
self._host_arg = host
|
|
202
|
+
self._port_arg = port
|
|
203
|
+
self._prefix = prefix or _DEFAULT_PREFIX
|
|
204
|
+
self._root = Path(root) if root is not None else None
|
|
205
|
+
self._dry_run = bool(dry_run)
|
|
206
|
+
self._transport = transport
|
|
207
|
+
|
|
208
|
+
def export(self, events) -> ExportResult:
|
|
209
|
+
"""Send one counter per (syscall, verdict). Returns an `ExportResult`; NEVER raises."""
|
|
210
|
+
cursor = _max_seq_cursor(events)
|
|
211
|
+
n = len(events)
|
|
212
|
+
host = resolve_host(self._host_arg, root=self._root)
|
|
213
|
+
port = resolve_port(self._port_arg, root=self._root)
|
|
214
|
+
|
|
215
|
+
if n == 0:
|
|
216
|
+
return ExportResult(
|
|
217
|
+
exported=0, detail=f"no new events for {host}:{port}", cursor=cursor)
|
|
218
|
+
|
|
219
|
+
lines = build_lines(events, prefix=self._prefix)
|
|
220
|
+
|
|
221
|
+
if self._dry_run:
|
|
222
|
+
return ExportResult(
|
|
223
|
+
exported=0,
|
|
224
|
+
detail=f"[dry-run] would send {len(lines)} counter(s) "
|
|
225
|
+
f"for {n} event(s) to {host}:{port}",
|
|
226
|
+
cursor=cursor,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
transport = self._transport if self._transport is not None else _UdpTransport()
|
|
230
|
+
try:
|
|
231
|
+
transport.send(host, port, lines)
|
|
232
|
+
except Exception as e: # noqa: BLE001 - advisory; report, don't crash the producer
|
|
233
|
+
return ExportResult(exported=0, detail=f"error: {e}", cursor=cursor)
|
|
234
|
+
|
|
235
|
+
# UDP is fire-and-forget: a clean send means the datagrams left this host, which
|
|
236
|
+
# is the strongest guarantee StatsD offers. We count the EVENTS exported (what
|
|
237
|
+
# the operator asked to ship), and note the counter line count in the detail.
|
|
238
|
+
return ExportResult(
|
|
239
|
+
exported=n,
|
|
240
|
+
detail=f"sent {len(lines)} counter(s) for {n} event(s) to {host}:{port}",
|
|
241
|
+
cursor=cursor,
|
|
242
|
+
)
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""The per-vendor hook-dialect renderers — a DRIVER (docs/217, the kernel/driver split).
|
|
2
|
+
|
|
3
|
+
> **The verdict is the kernel; the envelope is a driver.**
|
|
4
|
+
|
|
5
|
+
`hook_dialect.py` (the kernel seam) holds the dialect-neutral `HookVerdict`, the
|
|
6
|
+
`HookDialect` Protocol, the by-name `resolve_dialect`, and the ONE unshadowable
|
|
7
|
+
built-in: `ClaudeCodeDialect` (the default — byte-for-byte what `decide()` already
|
|
8
|
+
emits). Every OTHER host renderer — the ones that must name a specific vendor as
|
|
9
|
+
code (`CodexDialect`, `GeminiDialect`, `CursorDialect`) — lives HERE, in a driver,
|
|
10
|
+
discovered by name through the `dos.hook_dialects` entry-point group.
|
|
11
|
+
|
|
12
|
+
This is the exact same kernel/driver split as `judges` (the pure `Judge` protocol +
|
|
13
|
+
`AbstainJudge` baseline in the kernel; every *ruling* judge in `drivers/llm_judge`)
|
|
14
|
+
and `overlap_policy` (the pure scorer seam in the kernel; a model-backed scorer in a
|
|
15
|
+
driver). The litmus it satisfies (`tests/test_vendor_agnostic_kernel.py`): **no
|
|
16
|
+
non-driver kernel module names a vendor as a code identifier**, so no kernel
|
|
17
|
+
*adjudication* can branch on which vendor is acting. A dialect renderer legitimately
|
|
18
|
+
names its vendor — but it is OUTPUT formatting chosen explicitly by the operator
|
|
19
|
+
(`--dialect codex`), strictly downstream of an already-decided verdict, never a
|
|
20
|
+
decision. That is precisely why it belongs on the driver side of the line.
|
|
21
|
+
|
|
22
|
+
PURE: verdict in, host dict (or None for PASS) out. NO I/O, NO tool-input rewrite
|
|
23
|
+
key (the docs/191 §4 byte-author floor — a corrective rides a context/message field
|
|
24
|
+
as a fact to read, never a rewritten argument to use).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from dos.hook_dialect import ClaudeCodeDialect, HookAction, HookMoment, HookVerdict
|
|
32
|
+
|
|
33
|
+
# The default renderer, reused by the CC-identical Codex dialect. Importing the
|
|
34
|
+
# kernel from a driver is the allowed direction (layer 4 → layers 1–2).
|
|
35
|
+
_CLAUDE_CODE = ClaudeCodeDialect()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CodexDialect:
|
|
39
|
+
"""OpenAI Codex CLI — the cheapest dialect: the envelope is CC-identical.
|
|
40
|
+
|
|
41
|
+
Codex's `PreToolUse`/`PostToolUse` hooks honor the same `hookSpecificOutput`
|
|
42
|
+
shape (its field names were copied from CC almost verbatim). The one real
|
|
43
|
+
divergence is host COVERAGE — Codex only fires `PreToolUse` on its
|
|
44
|
+
Bash/apply_patch/unified_exec/mcp handlers — which is a host limit, not a render
|
|
45
|
+
difference: DOS emits the right bytes; Codex simply won't call the hook on every
|
|
46
|
+
tool. So this dialect delegates to the CC renderer (kept as its own class for an
|
|
47
|
+
explicit by-name entry + so a future Codex-specific divergence has a home).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name = "codex"
|
|
51
|
+
|
|
52
|
+
def render(self, verdict: HookVerdict) -> Optional[dict]:
|
|
53
|
+
return _CLAUDE_CODE.render(verdict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GeminiDialect:
|
|
57
|
+
"""Google Gemini CLI — `BeforeTool` / `AfterTool` / `AfterAgent` hooks.
|
|
58
|
+
|
|
59
|
+
The DENY envelope is MOMENT-DEPENDENT, because Gemini 0.45.x gates the two
|
|
60
|
+
moments on DIFFERENT fields (verified against the CLI 0.45.2 bundle, 2026-06-09):
|
|
61
|
+
|
|
62
|
+
* A `BeforeTool` deny — STOP THE TOOL BEFORE IT RUNS — is enforced by
|
|
63
|
+
`shouldStopExecution()`, whose body is literally `return this.continue ===
|
|
64
|
+
false`. So a PRE deny must emit `{"continue": false, "stopReason": …}`. A
|
|
65
|
+
`{"decision": "deny"}` here is IGNORED on the tool-execution path (it only
|
|
66
|
+
feeds `isBlockingDecision()`, which the BeforeTool gate does NOT consult) —
|
|
67
|
+
the tool runs anyway. This was the silent fail-open: DOS emitted
|
|
68
|
+
`{"decision":"deny"}` and a live Gemini wrote the file regardless (docs/268).
|
|
69
|
+
|
|
70
|
+
* An `AfterAgent` deny — REFUSE TO STOP — is enforced by `isBlockingDecision()`
|
|
71
|
+
(`decision === "block" || decision === "deny"`). So the STOP moment renders
|
|
72
|
+
`{"decision": "block", "reason": …}` (block, the documented stop refusal).
|
|
73
|
+
|
|
74
|
+
A WARN (turn-preserving) injects context via `hookSpecificOutput.additionalContext`
|
|
75
|
+
— Gemini reads it into the model's context for self-correction without blocking.
|
|
76
|
+
|
|
77
|
+
`getEffectiveReason()` prefers `stopReason` then `reason`, so the PRE deny carries
|
|
78
|
+
its why on `stopReason` and the corrective fact (if any) on additionalContext.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name = "gemini"
|
|
82
|
+
|
|
83
|
+
def render(self, verdict: HookVerdict) -> Optional[dict]:
|
|
84
|
+
if verdict.action is HookAction.PASS:
|
|
85
|
+
return None
|
|
86
|
+
if verdict.action is HookAction.DENY:
|
|
87
|
+
if verdict.moment is HookMoment.PRE:
|
|
88
|
+
# BeforeTool: stop the tool via `continue: false` (the field
|
|
89
|
+
# `shouldStopExecution()` actually checks). `stopReason` is the why
|
|
90
|
+
# `getEffectiveReason()` surfaces.
|
|
91
|
+
out: dict = {"continue": False, "stopReason": verdict.reason}
|
|
92
|
+
if verdict.context:
|
|
93
|
+
out["hookSpecificOutput"] = {"additionalContext": verdict.context}
|
|
94
|
+
return out
|
|
95
|
+
# AfterAgent (or any non-PRE) refusal: block the stop via the
|
|
96
|
+
# decision field `isBlockingDecision()` consults.
|
|
97
|
+
out = {"decision": "block", "reason": verdict.reason}
|
|
98
|
+
if verdict.context:
|
|
99
|
+
out["hookSpecificOutput"] = {"additionalContext": verdict.context}
|
|
100
|
+
return out
|
|
101
|
+
return {"hookSpecificOutput": {"additionalContext": verdict.context}}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AntigravityDialect:
|
|
105
|
+
"""Google Antigravity (IDE + CLI) — `PreToolUse`/`PostToolUse`/`Stop` hooks.
|
|
106
|
+
|
|
107
|
+
Antigravity is a HYBRID of the two grammars DOS already speaks, which is exactly
|
|
108
|
+
why it earns its own renderer rather than aliasing an existing one:
|
|
109
|
+
|
|
110
|
+
* its hook CONFIG file is Claude-Code-SHAPED (group-wrapped `matcher`+`hooks`
|
|
111
|
+
entries under `PreToolUse`/`PostToolUse`/`Stop` — see `antigravity_install_spec`),
|
|
112
|
+
BUT
|
|
113
|
+
* its hook OUTPUT grammar is Gemini-SHAPED: a script writes a JSON object on
|
|
114
|
+
stdout carrying a top-level `decision` key set to `"deny"` or `"allow"`, with
|
|
115
|
+
an optional `reason` (NOT Claude-Code's nested `permissionDecision`).
|
|
116
|
+
|
|
117
|
+
So the install spec is group-wrapped like CC, but the bytes a verdict RENDERS to
|
|
118
|
+
are `{"decision": "deny", "reason": …}` like Gemini. Web-grounded 2026-06-09
|
|
119
|
+
(Antigravity hooks docs + the CLI migration guide — "Antigravity hooks receive
|
|
120
|
+
JSON on standard input and read a JSON object on standard output containing a
|
|
121
|
+
decision key set to `allow` or `deny`").
|
|
122
|
+
|
|
123
|
+
The corrective FACT (a provenance DENY's `context`) is appended to the operator-
|
|
124
|
+
facing `reason` (Antigravity's documented output vocabulary is `decision`/`reason`;
|
|
125
|
+
it does not document a separate context channel, so re-surfacing the fact through
|
|
126
|
+
`reason` is the lossless, no-extra-key move — the docs/191 §4 byte-author floor:
|
|
127
|
+
a fact to read, never a rewritten argument). A WARN (turn-preserving, do NOT
|
|
128
|
+
block) emits a bare `{"reason": …}` with NO `decision` key — inert to the
|
|
129
|
+
allow/deny gate, so it adds context without withholding the call.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
name = "antigravity"
|
|
133
|
+
|
|
134
|
+
def render(self, verdict: HookVerdict) -> Optional[dict]:
|
|
135
|
+
if verdict.action is HookAction.PASS:
|
|
136
|
+
return None
|
|
137
|
+
if verdict.action is HookAction.DENY:
|
|
138
|
+
out = {"decision": "deny"}
|
|
139
|
+
# Join reason + the corrective fact into the one operator-facing field
|
|
140
|
+
# Antigravity reads (it has no separate additionalContext channel). Keep
|
|
141
|
+
# them distinct, space-joined, with neither half left dangling.
|
|
142
|
+
reason = " ".join(p for p in (verdict.reason, verdict.context) if p).strip()
|
|
143
|
+
if reason:
|
|
144
|
+
out["reason"] = reason
|
|
145
|
+
return out
|
|
146
|
+
# WARN → a bare reason with no decision (inert to the allow/deny gate, so it
|
|
147
|
+
# re-surfaces context without blocking — Antigravity's only turn-preserving path).
|
|
148
|
+
return {"reason": verdict.context}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class HermesDialect:
|
|
152
|
+
"""Nous Research's **Hermes Agent** — the `pre_tool_call` / `post_tool_call`
|
|
153
|
+
SHELL hook (docs/278).
|
|
154
|
+
|
|
155
|
+
Hermes is a Python autonomous-agent framework whose hook system fires a
|
|
156
|
+
user-configured shell command *before* a tool runs (inside
|
|
157
|
+
`handle_function_call()`), and the FIRST matching "block" directive short-circuits
|
|
158
|
+
the tool, returning the message to the model as that tool's error. Unlike OpenClaw
|
|
159
|
+
(whose real `before_tool_call` hook is an in-process TypeScript return value, NOT
|
|
160
|
+
stdout bytes — so it has no stdout-renderer consumer and is deliberately NOT given
|
|
161
|
+
a dialect here) and SwarmClaw (no documented pre-tool interception hook at all),
|
|
162
|
+
Hermes' shell hook is a genuine "emit-JSON-on-stdout" surface — exactly the shape
|
|
163
|
+
`dos hook pretool --dialect hermes` produces.
|
|
164
|
+
|
|
165
|
+
DENY shape (verified against the Hermes hooks doc, 2026-06-09 —
|
|
166
|
+
`hermes-agent.nousresearch.com/docs/user-guide/features/hooks`): a hook BLOCKS by
|
|
167
|
+
printing `{"decision": "block", "reason": "…"}` on stdout. Hermes ALSO accepts the
|
|
168
|
+
equivalent `{"action": "block", "message": "…"}` and "normalises internally", but
|
|
169
|
+
DOS emits the canonical `decision`/`reason` form (the same field NAMES Gemini's
|
|
170
|
+
AfterAgent and Claude-Code's stop refusal use — one fewer shape for an operator to
|
|
171
|
+
learn). ALLOW is an empty object `{}` (or any non-matching output).
|
|
172
|
+
|
|
173
|
+
WARN is the one lossy moment: the Hermes shell-hook grammar documents only
|
|
174
|
+
block-vs-allow — there is NO turn-preserving "add context without blocking"
|
|
175
|
+
channel the way Cursor (`agent_message`), Gemini/CC (`additionalContext`), or
|
|
176
|
+
Antigravity (a bare `reason`) expose. So a DOS WARN renders to the ALLOW object
|
|
177
|
+
`{}` (it MUST NOT block — a WARN is turn-preserving), and the corrective `context`
|
|
178
|
+
is necessarily dropped on this host. That is a Hermes coverage limit, surfaced
|
|
179
|
+
honestly rather than smuggled onto a field Hermes does not read: a WARN through
|
|
180
|
+
`--dialect hermes` is a non-blocking pass, no more. (A Hermes integrator who wants
|
|
181
|
+
the context delivered should use the DENY path with a soft reason, or the Python
|
|
182
|
+
plugin hook, which is out of the stdout-renderer model.)
|
|
183
|
+
|
|
184
|
+
Like every dialect this is the docs/191 §4 byte-author floor: a DENY carries a
|
|
185
|
+
`reason` (a fact to read), never a rewritten tool argument. The block bytes do not
|
|
186
|
+
vary by MOMENT (Hermes' `pre_tool_call` and `post_tool_call` read the same
|
|
187
|
+
decision field; `post` cannot actually halt a finished tool, a host coverage
|
|
188
|
+
matter, not a render difference) — so `render` is moment-agnostic, unlike the
|
|
189
|
+
Gemini renderer whose PRE/STOP deny fields genuinely differ.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
name = "hermes"
|
|
193
|
+
|
|
194
|
+
def render(self, verdict: HookVerdict) -> Optional[dict]:
|
|
195
|
+
if verdict.action is HookAction.PASS:
|
|
196
|
+
return None
|
|
197
|
+
if verdict.action is HookAction.DENY:
|
|
198
|
+
# Join the operator-facing reason and any corrective fact into the one
|
|
199
|
+
# field Hermes surfaces (`reason`); keep them distinct, space-joined, with
|
|
200
|
+
# neither half left dangling. The canonical block shape.
|
|
201
|
+
reason = " ".join(p for p in (verdict.reason, verdict.context) if p).strip()
|
|
202
|
+
out: dict = {"decision": "block"}
|
|
203
|
+
if reason:
|
|
204
|
+
out["reason"] = reason
|
|
205
|
+
return out
|
|
206
|
+
# WARN → the ALLOW object. Hermes' shell hook has no non-blocking context
|
|
207
|
+
# channel, so a turn-preserving verdict can only PASS here (context dropped).
|
|
208
|
+
return {}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class CursorDialect:
|
|
212
|
+
"""Cursor — `beforeShellExecution`/`beforeMCPExecution`/`preToolUse` hooks.
|
|
213
|
+
|
|
214
|
+
Cursor's deny grammar is a top-level `{"permission": "deny"}`; the human/agent
|
|
215
|
+
messages ride `user_message`/`agent_message`. A DOS WARN (turn-preserving, do
|
|
216
|
+
NOT block) maps to `{"permission": "allow", "agent_message": <context>}` —
|
|
217
|
+
Cursor has no "pass-but-add-context" that is not an allow, so we allow-with-message.
|
|
218
|
+
We NEVER emit Cursor's `updated_input` rewrite key (the docs/191 §4 byte-author
|
|
219
|
+
floor — minting a corrective argument for the agent is forbidden); the corrective
|
|
220
|
+
rides `agent_message` as a fact to read, not a value to use.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
name = "cursor"
|
|
224
|
+
|
|
225
|
+
def render(self, verdict: HookVerdict) -> Optional[dict]:
|
|
226
|
+
if verdict.action is HookAction.PASS:
|
|
227
|
+
return None
|
|
228
|
+
if verdict.action is HookAction.DENY:
|
|
229
|
+
out = {"permission": "deny"}
|
|
230
|
+
if verdict.reason:
|
|
231
|
+
out["agent_message"] = verdict.reason
|
|
232
|
+
if verdict.context:
|
|
233
|
+
# Append the corrective fact to the agent-facing message (a fact, not
|
|
234
|
+
# a rewritten arg). Keep reason + context distinct, joined by a space.
|
|
235
|
+
out["agent_message"] = (out.get("agent_message", "") + " " + verdict.context).strip()
|
|
236
|
+
return out
|
|
237
|
+
# WARN → allow + a message (Cursor's only turn-preserving "add context" path).
|
|
238
|
+
return {"permission": "allow", "agent_message": verdict.context}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ===========================================================================
|
|
242
|
+
# Per-vendor INSTALL specs (docs/221) — where/how `dos init --hooks <host>` wires
|
|
243
|
+
# the DOS hooks into each runtime's OWN config file. These are the install-side
|
|
244
|
+
# sibling of the dialect renderers above, and they belong HERE for the SAME reason:
|
|
245
|
+
# a spec must name its vendor (`cursor`/`codex`/`gemini`) and its config-file path
|
|
246
|
+
# as code, which the vendor-agnostic-kernel litmus forbids in a non-driver kernel
|
|
247
|
+
# module. The kernel (`hook_install.py`) holds only the pure machinery + the
|
|
248
|
+
# `claude-code` default; it discovers these by name through the `dos.hook_installs`
|
|
249
|
+
# entry-point group (see pyproject.toml). Facts web-grounded 2026-06-07 (docs/221
|
|
250
|
+
# §1a); a vendor moving is a one-line edit to its row here, never a kernel change.
|
|
251
|
+
# ===========================================================================
|
|
252
|
+
from dos.hook_install import ConfigFormat, HostHookSpec # noqa: E402 (driver→kernel, allowed)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cursor_install_spec() -> HostHookSpec:
|
|
256
|
+
"""Cursor — `.cursor/hooks.json` (JSON, requires `{"version": 1}`).
|
|
257
|
+
|
|
258
|
+
PRE is TWO events (`beforeShellExecution` + `beforeMCPExecution`) so a refused
|
|
259
|
+
call is caught whether it is a shell command or an MCP tool. Entries are FLAT
|
|
260
|
+
`{"command": …}` (no `type`, no group wrapper). The `stop` event fires when the
|
|
261
|
+
agent loop ends.
|
|
262
|
+
"""
|
|
263
|
+
return HostHookSpec(
|
|
264
|
+
host="cursor",
|
|
265
|
+
config_path=(".cursor", "hooks.json"),
|
|
266
|
+
fmt=ConfigFormat.JSON,
|
|
267
|
+
pre_events=("beforeShellExecution", "beforeMCPExecution"),
|
|
268
|
+
post_events=("afterFileEdit",),
|
|
269
|
+
stop_events=("stop",),
|
|
270
|
+
dialect_flag="--dialect cursor",
|
|
271
|
+
json_entry_has_type=False, # Cursor entries are flat {"command": …}.
|
|
272
|
+
json_group_wraps=False,
|
|
273
|
+
json_version=1, # hooks.json requires {"version": 1}.
|
|
274
|
+
note='Cursor honors "failClosed": true on the PRE deny — add it per-hook if '
|
|
275
|
+
"you want a DOS crash to BLOCK the call (DOS itself fails to PASS; the "
|
|
276
|
+
"host's fail-on-crash direction is your call).",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def codex_install_spec() -> HostHookSpec:
|
|
281
|
+
"""OpenAI Codex CLI — `.codex/config.toml` (TOML, CC-shaped tables).
|
|
282
|
+
|
|
283
|
+
`[[hooks.PreToolUse]]` → `[[hooks.PreToolUse.hooks]]` with `type="command"`.
|
|
284
|
+
Codex fires `PreToolUse` only on its Bash/apply_patch/unified_exec/mcp handlers
|
|
285
|
+
(a host coverage limit, tracked upstream) — DOS wires the right bytes; Codex
|
|
286
|
+
simply won't call the hook on every tool.
|
|
287
|
+
"""
|
|
288
|
+
return HostHookSpec(
|
|
289
|
+
host="codex",
|
|
290
|
+
config_path=(".codex", "config.toml"),
|
|
291
|
+
fmt=ConfigFormat.TOML,
|
|
292
|
+
pre_events=("PreToolUse",),
|
|
293
|
+
post_events=("PostToolUse",),
|
|
294
|
+
stop_events=("Stop",),
|
|
295
|
+
dialect_flag="--dialect codex",
|
|
296
|
+
note="Codex fires PreToolUse only on its Bash / apply_patch / unified_exec / "
|
|
297
|
+
"mcp handlers (a host coverage limit, tracked upstream) — DOS wires the "
|
|
298
|
+
"right bytes; Codex simply won't call the hook on every tool.",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def gemini_install_spec() -> HostHookSpec:
|
|
303
|
+
"""Google Gemini CLI — `.gemini/settings.json` (JSON).
|
|
304
|
+
|
|
305
|
+
Gemini's own event vocabulary: `BeforeTool` / `AfterTool` / `AfterAgent`.
|
|
306
|
+
`AfterAgent` fires "once per turn after the model generates its final response"
|
|
307
|
+
— the Stop analogue where `dos hook stop` refuses a premature done.
|
|
308
|
+
|
|
309
|
+
CONFIG SHAPE — group-wrapped, byte-identical to Claude Code (verified against the
|
|
310
|
+
Gemini CLI 0.45.2 bundle, 2026-06-09). Each event maps to a list of
|
|
311
|
+
`{"hooks": [{"type": "command", "command": …}]}` matcher-GROUPS, NOT a flat
|
|
312
|
+
`{"type", "command"}` entry: the loader's `processHookDefinition` discards any
|
|
313
|
+
definition where `Array.isArray(definition.hooks)` is false (it logs
|
|
314
|
+
"Discarding invalid hook definition for BeforeTool …" and drops it). Gemini
|
|
315
|
+
adopted Claude-Code's hook-config format — that is why `gemini hooks migrate`
|
|
316
|
+
(from Claude Code) exists — so the install shape is CC's, the same
|
|
317
|
+
`json_group_wraps=True` as `claude_code_spec`. The inner hook is validated by
|
|
318
|
+
`validateHookConfig`: `type` ∈ {command, plugin, runtime} and a non-empty
|
|
319
|
+
`command` when `type == "command"`.
|
|
320
|
+
|
|
321
|
+
OUTPUT SHAPE — the renderers still diverge from CC. `BeforeTool` honors a
|
|
322
|
+
top-level `{"decision": "deny"}` (Gemini's tool gate throws "denied by policy"
|
|
323
|
+
on `decision === "deny"`), which is what `--dialect gemini` produces via
|
|
324
|
+
`GeminiDialect`. `AfterAgent` blocks the stop on `isBlockingDecision()`, which is
|
|
325
|
+
true for BOTH `"block"` AND `"deny"` — so a stop refusal rendered through
|
|
326
|
+
`--dialect gemini` (a `{"decision": "deny", "reason": …}`) is honored just as the
|
|
327
|
+
CC-native `{"decision": "block"}` would be.
|
|
328
|
+
|
|
329
|
+
Earlier this spec wrote flat entries (`json_group_wraps=False`) — that matched a
|
|
330
|
+
pre-0.45 Gemini shape and made 0.45.2 discard EVERY DOS hook at load time. The
|
|
331
|
+
group-wrap fix lands the hooks; giving the `stop` verb a `--dialect` flag lands
|
|
332
|
+
the AfterAgent hook (it previously exited 2 on the unrecognized flag) — docs/268.
|
|
333
|
+
"""
|
|
334
|
+
return HostHookSpec(
|
|
335
|
+
host="gemini",
|
|
336
|
+
config_path=(".gemini", "settings.json"),
|
|
337
|
+
fmt=ConfigFormat.JSON,
|
|
338
|
+
pre_events=("BeforeTool",),
|
|
339
|
+
post_events=("AfterTool",),
|
|
340
|
+
stop_events=("AfterAgent",),
|
|
341
|
+
dialect_flag="--dialect gemini",
|
|
342
|
+
json_entry_has_type=True,
|
|
343
|
+
json_group_wraps=True, # CC-shaped: entries nest under {"hooks": [...]} groups.
|
|
344
|
+
json_version=None,
|
|
345
|
+
note="Gemini 0.45.x adopted Claude-Code's group-wrapped hook-config shape "
|
|
346
|
+
"(hence `gemini hooks migrate`). BeforeTool honors {\"decision\":\"deny\"}, "
|
|
347
|
+
"AfterAgent honors both {\"decision\":\"block\"} and \"deny\" — all rendered "
|
|
348
|
+
"via --dialect gemini.",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def antigravity_install_spec() -> HostHookSpec:
|
|
353
|
+
"""Google Antigravity (IDE + CLI) — `.agents/hooks.json` (JSON, CC-shaped groups).
|
|
354
|
+
|
|
355
|
+
Antigravity adopted Claude-Code's hook-CONFIG shape: each event maps to a list of
|
|
356
|
+
matcher-GROUPS, each `{"hooks": [{"type": "command", "command": …}]}` (a group
|
|
357
|
+
with no `matcher` matches every tool — the right default for a DOS hook that must
|
|
358
|
+
adjudicate ALL tools, not one). The event names are the CC vocabulary too:
|
|
359
|
+
`PreToolUse` / `PostToolUse` / `Stop` (Antigravity also fires `BeforeModel` /
|
|
360
|
+
`AfterModel` / `SessionStart` / `SubAgentStop`, but DOS's three lifecycle moments
|
|
361
|
+
map onto the tool + stop seams). So this spec is `json_group_wraps=True` exactly
|
|
362
|
+
like `claude_code_spec`.
|
|
363
|
+
|
|
364
|
+
What it does NOT share with CC is the hook OUTPUT grammar — Antigravity reads a
|
|
365
|
+
top-level `{"decision": "deny"}` (Gemini-shaped), which is why it carries
|
|
366
|
+
`--dialect antigravity` (the `AntigravityDialect` renderer), NOT the implicit CC
|
|
367
|
+
default. Group-wrapped config + Gemini-shaped output is a combination no other
|
|
368
|
+
host has; the `dialect_flag` (data) keeps the wired command pointed at the right
|
|
369
|
+
renderer without `command_for` ever comparing a vendor literal.
|
|
370
|
+
|
|
371
|
+
Config-file facts web-grounded 2026-06-09 (Antigravity hooks docs + the
|
|
372
|
+
`Migrating to Antigravity CLI` guide: `.agents/hooks.json`, `PreToolUse` groups
|
|
373
|
+
with `matcher`+`hooks`+`type/command`, `{"decision":"deny","reason":…}` output).
|
|
374
|
+
"""
|
|
375
|
+
return HostHookSpec(
|
|
376
|
+
host="antigravity",
|
|
377
|
+
config_path=(".agents", "hooks.json"),
|
|
378
|
+
fmt=ConfigFormat.JSON,
|
|
379
|
+
pre_events=("PreToolUse",),
|
|
380
|
+
post_events=("PostToolUse",),
|
|
381
|
+
stop_events=("Stop",),
|
|
382
|
+
dialect_flag="--dialect antigravity",
|
|
383
|
+
json_entry_has_type=True,
|
|
384
|
+
json_group_wraps=True, # CC-shaped: entries nest under {"hooks": [...]} groups.
|
|
385
|
+
json_version=None,
|
|
386
|
+
note="Antigravity also fires BeforeModel / AfterModel / SessionStart / "
|
|
387
|
+
"SubAgentStop; DOS wires the tool + stop seams (PreToolUse / PostToolUse "
|
|
388
|
+
"/ Stop). A workspace .agents/hooks.json takes precedence over the global "
|
|
389
|
+
"one. The hook OUTPUT is top-level {\"decision\":\"deny\"} (Gemini-shaped, "
|
|
390
|
+
"via --dialect antigravity), even though the CONFIG is Claude-Code-shaped.",
|
|
391
|
+
)
|