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.
Files changed (178) hide show
  1. dos/__init__.py +261 -0
  2. dos/_bin/dos-hook.exe +0 -0
  3. dos/_filelock.py +255 -0
  4. dos/_job_policy.py +97 -0
  5. dos/_tree.py +145 -0
  6. dos/admission.py +433 -0
  7. dos/answer_shape.py +299 -0
  8. dos/arbiter.py +859 -0
  9. dos/archive_lock.py +266 -0
  10. dos/arg_provenance.py +814 -0
  11. dos/attest.py +472 -0
  12. dos/breaker.py +311 -0
  13. dos/churn.py +226 -0
  14. dos/claim_extract.py +229 -0
  15. dos/claim_ttl.py +150 -0
  16. dos/cli.py +8721 -0
  17. dos/commit_audit.py +666 -0
  18. dos/completion.py +466 -0
  19. dos/concurrency_class.py +154 -0
  20. dos/config.py +1380 -0
  21. dos/config_lint.py +464 -0
  22. dos/cooldown.py +390 -0
  23. dos/coverage.py +387 -0
  24. dos/dangling_intent.py +287 -0
  25. dos/data_class.py +397 -0
  26. dos/decisions.py +1274 -0
  27. dos/decisions_tui.py +251 -0
  28. dos/dispatch_top.py +740 -0
  29. dos/dispatch_top_tui.py +116 -0
  30. dos/drivers/__init__.py +40 -0
  31. dos/drivers/ci_status.py +630 -0
  32. dos/drivers/citation_resolve.py +703 -0
  33. dos/drivers/decision_stop.py +98 -0
  34. dos/drivers/export_file.py +173 -0
  35. dos/drivers/export_otlp.py +275 -0
  36. dos/drivers/export_statsd.py +242 -0
  37. dos/drivers/hook_dialects.py +391 -0
  38. dos/drivers/job.py +47 -0
  39. dos/drivers/llm_judge.py +360 -0
  40. dos/drivers/memory_recall.py +1231 -0
  41. dos/drivers/notify_slack.py +373 -0
  42. dos/drivers/notify_webhook.py +251 -0
  43. dos/drivers/operator_judge.py +114 -0
  44. dos/drivers/os_acceptance.py +228 -0
  45. dos/drivers/paste_log.py +132 -0
  46. dos/drivers/plan_scope.py +133 -0
  47. dos/drivers/self_improve.py +375 -0
  48. dos/drivers/similarity_judge.py +249 -0
  49. dos/drivers/state_diff.py +274 -0
  50. dos/drivers/supervisor.py +347 -0
  51. dos/drivers/watchdog.py +363 -0
  52. dos/drivers/workshop.py +160 -0
  53. dos/durable_schema.py +344 -0
  54. dos/effect_witness.py +393 -0
  55. dos/efficiency.py +318 -0
  56. dos/enforce.py +414 -0
  57. dos/enumerate.py +776 -0
  58. dos/env_print.py +378 -0
  59. dos/event_severity.py +258 -0
  60. dos/evidence.py +692 -0
  61. dos/exec_capability.py +256 -0
  62. dos/export_cursor.py +143 -0
  63. dos/exporter.py +320 -0
  64. dos/firing_label.py +353 -0
  65. dos/fleet_roll.py +226 -0
  66. dos/gate_classify.py +827 -0
  67. dos/gh4_coverage.py +179 -0
  68. dos/git_delta.py +122 -0
  69. dos/guard.py +215 -0
  70. dos/health.py +552 -0
  71. dos/help_summary.py +519 -0
  72. dos/home.py +934 -0
  73. dos/hook_binary.py +194 -0
  74. dos/hook_dialect.py +271 -0
  75. dos/hook_exit.py +191 -0
  76. dos/hook_install.py +437 -0
  77. dos/id_alloc.py +304 -0
  78. dos/improve.py +499 -0
  79. dos/intent_ledger.py +635 -0
  80. dos/interpret.py +176 -0
  81. dos/intervention.py +769 -0
  82. dos/intervention_eval.py +371 -0
  83. dos/journal_delta.py +308 -0
  84. dos/judge_eval.py +328 -0
  85. dos/judges.py +366 -0
  86. dos/lane_infer.py +127 -0
  87. dos/lane_journal.py +1001 -0
  88. dos/lane_lease.py +952 -0
  89. dos/lane_overlap.py +228 -0
  90. dos/lease_health.py +282 -0
  91. dos/lifecycle.py +211 -0
  92. dos/liveness.py +352 -0
  93. dos/lock_modes.py +185 -0
  94. dos/log_source.py +395 -0
  95. dos/loop_decide.py +1746 -0
  96. dos/marker_gate.py +254 -0
  97. dos/marker_sensor.py +396 -0
  98. dos/noop_streak.py +280 -0
  99. dos/notify.py +479 -0
  100. dos/observe.py +175 -0
  101. dos/oracle.py +1661 -0
  102. dos/overlap_eval.py +214 -0
  103. dos/overlap_policy.py +342 -0
  104. dos/packet_sidecar.py +267 -0
  105. dos/phase_shipped.py +1985 -0
  106. dos/pick_priority.py +225 -0
  107. dos/pickable.py +369 -0
  108. dos/picker_oracle.py +1037 -0
  109. dos/plan_board.py +513 -0
  110. dos/plan_board_tui.py +113 -0
  111. dos/plan_source.py +455 -0
  112. dos/posttool_sensor.py +528 -0
  113. dos/precursor_gate.py +499 -0
  114. dos/precursor_gate_eval.py +239 -0
  115. dos/preflight.py +825 -0
  116. dos/pretool_sensor.py +490 -0
  117. dos/proc_delta.py +181 -0
  118. dos/productivity.py +296 -0
  119. dos/provider_limit.py +242 -0
  120. dos/py.typed +4 -0
  121. dos/reason_morphology.py +299 -0
  122. dos/reasons.py +449 -0
  123. dos/reconcile.py +173 -0
  124. dos/recurring_wedge.py +206 -0
  125. dos/render.py +393 -0
  126. dos/result_state.py +468 -0
  127. dos/resume.py +578 -0
  128. dos/resume_evidence.py +293 -0
  129. dos/retention.py +344 -0
  130. dos/reward.py +372 -0
  131. dos/rewind.py +587 -0
  132. dos/rewind_evidence.py +168 -0
  133. dos/rewind_tokens.py +252 -0
  134. dos/run_id.py +342 -0
  135. dos/scope.py +520 -0
  136. dos/scope_source.py +382 -0
  137. dos/scout.py +982 -0
  138. dos/self_modify.py +209 -0
  139. dos/sibling_scan.py +569 -0
  140. dos/skills/EXAMPLES.md +584 -0
  141. dos/skills/dos-class-cycle/SKILL.md +107 -0
  142. dos/skills/dos-dispatch/SKILL.md +177 -0
  143. dos/skills/dos-dispatch-loop/SKILL.md +254 -0
  144. dos/skills/dos-goal-gate/SKILL.md +269 -0
  145. dos/skills/dos-next-up/SKILL.md +231 -0
  146. dos/skills/dos-promote/SKILL.md +114 -0
  147. dos/skills/dos-replan/SKILL.md +159 -0
  148. dos/skills/dos-replan-loop/SKILL.md +114 -0
  149. dos/skills/dos-self-improve/SKILL.md +213 -0
  150. dos/skills/dos-supervise-loop/SKILL.md +180 -0
  151. dos/skills/dos-unstick/SKILL.md +108 -0
  152. dos/skills/dos-witness-claim/SKILL.md +251 -0
  153. dos/stamp.py +1002 -0
  154. dos/state_health.py +387 -0
  155. dos/status.py +114 -0
  156. dos/stop_policy.py +334 -0
  157. dos/supervise.py +1014 -0
  158. dos/testwitness.py +392 -0
  159. dos/timeline.py +1027 -0
  160. dos/tokens.py +485 -0
  161. dos/tool_stream.py +393 -0
  162. dos/tool_stream_eval.py +226 -0
  163. dos/trace.py +524 -0
  164. dos/verdict.py +140 -0
  165. dos/verdict_cli.py +189 -0
  166. dos/verdict_journal.py +497 -0
  167. dos/verdict_rollup.py +217 -0
  168. dos/verdicts.py +181 -0
  169. dos/wedge_reason.py +282 -0
  170. dos_kernel-0.22.0.dist-info/METADATA +859 -0
  171. dos_kernel-0.22.0.dist-info/RECORD +178 -0
  172. dos_kernel-0.22.0.dist-info/WHEEL +5 -0
  173. dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
  174. dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
  175. dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
  176. dos_mcp/__init__.py +52 -0
  177. dos_mcp/py.typed +2 -0
  178. 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
+ )