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
dos/hook_binary.py ADDED
@@ -0,0 +1,194 @@
1
+ """Locate the native `dos-hook` fast-path binary bundled in the installed package (docs/286).
2
+
3
+ The per-tool-call hook hot path (`dos hook pretool`/`posttool`) pays ~0.3-0.8 s of
4
+ Python interpreter cold-start on EVERY tool call; a static Go binary serves the same
5
+ decision in ~10 ms (docs/125/270, the 16-43x win). That binary ships two ways:
6
+
7
+ * **The Claude Code plugin** carries it in its git tree (`claude-plugin/bin/`,
8
+ docs/125 GHF4) and a shell launcher dispatches to it.
9
+ * **A `pip install dos-kernel` per-platform wheel** (docs/286) bundles exactly the
10
+ one binary for the installing machine's OS/arch into the package, at
11
+ `dos/_bin/dos-hook[.exe]`. THIS module is the in-package locator for that copy —
12
+ the wheel analogue of the plugin's POSIX `bin/dos-hook` launcher, consulted by the
13
+ CLI hook verbs so a pip user's `dos hook pretool` transparently routes through the
14
+ native binary when one is present.
15
+
16
+ This is PURE stdlib and adds NO runtime dependency (the binary is package DATA, the
17
+ same one-way arrow as the skill pack). It only RESOLVES a path + checks it is an
18
+ executable file — it launches nothing; the CLI is the call site that execs it.
19
+
20
+ **The fallback discipline (docs/100) is absolute.** On a pure-Python install (the
21
+ sdist, or any arch with no matching wheel, or a clean dev checkout where the binary is
22
+ gitignored), `native_hook_binary()` returns None and the caller runs the in-process
23
+ Python decider — un-accelerated, never broken. No machine is ever BLOCKED by a missing
24
+ accelerator; the binary is only ever a speed-up.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import platform
31
+ import subprocess
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ # The native binary's DELEGATE sentinel (docs/125): it exits 3 for a verb/moment it
36
+ # does not own natively, signalling "let Python decide." A clean native decision is
37
+ # exit 0 (it already emitted any dialect to stdout). Anything else is abnormal and we
38
+ # fall through to Python too (fail-safe).
39
+ DELEGATE_EXIT = 3
40
+
41
+ # The package-data dir the per-platform wheel drops the binary into. Resolved against
42
+ # THIS module's location (the installed package), NOT a workspace root — the binary is
43
+ # part of the package, wherever pip put it.
44
+ _BIN_DIR = Path(__file__).resolve().parent / "_bin"
45
+
46
+ # The verbs the native binary serves on the per-tool-call hot path. `stop` fires once
47
+ # per TURN (negligible cold-start) and the oracle port it needs is heavier (docs/125
48
+ # §8.2), so it stays Python on the pip path; the binary itself DELEGATEs it anyway.
49
+ NATIVE_HOOK_VERBS = frozenset({"pretool", "posttool"})
50
+
51
+ # The env opt-out, matching the plugin path's flag. Unset/anything-but-"0" => native
52
+ # allowed; "0" => force the Python verb (the differential-oracle / debug escape hatch).
53
+ _DISABLE_ENV = "DOS_HOOK_NATIVE"
54
+
55
+
56
+ def _host_goos_goarch() -> tuple[str, str]:
57
+ """This interpreter's (GOOS, GOARCH) tokens — the same mapping `build_hook_binary.py`
58
+ and the POSIX launcher use, so the name we look up matches the name the build emits."""
59
+ goos = {"windows": "windows", "darwin": "darwin", "linux": "linux"}.get(
60
+ platform.system().lower(), platform.system().lower()
61
+ )
62
+ m = platform.machine().lower()
63
+ goarch = {
64
+ "x86_64": "amd64", "amd64": "amd64",
65
+ "arm64": "arm64", "aarch64": "arm64",
66
+ }.get(m, m)
67
+ return goos, goarch
68
+
69
+
70
+ def bundled_binary_name() -> str:
71
+ """The plain in-package binary name for this platform: `dos-hook` or `dos-hook.exe`.
72
+
73
+ The per-platform wheel ships ONE binary per wheel, so it is the un-suffixed
74
+ `dos-hook[.exe]` (NOT the `dos-hook-<os>-<arch>` matrix names the PLUGIN bundle
75
+ uses — the plugin carries all arches in one dir and disambiguates by name; a wheel
76
+ carries only its own arch, so no disambiguation is needed)."""
77
+ goos, _ = _host_goos_goarch()
78
+ return "dos-hook.exe" if goos == "windows" else "dos-hook"
79
+
80
+
81
+ def native_hook_enabled() -> bool:
82
+ """True unless `DOS_HOOK_NATIVE=0` forces the Python verb (the opt-out)."""
83
+ return os.environ.get(_DISABLE_ENV, "").strip() != "0"
84
+
85
+
86
+ def native_hook_binary() -> Path | None:
87
+ """The bundled native dos-hook for THIS platform, or None if there isn't one.
88
+
89
+ Returns the path iff a matching, executable regular file is present at
90
+ `dos/_bin/dos-hook[.exe]` in the installed package; None otherwise (a pure-Python /
91
+ sdist install, an off-matrix arch, a clean dev checkout, or the `DOS_HOOK_NATIVE=0`
92
+ opt-out). The caller falls back to the in-process Python hook decider when None.
93
+
94
+ Never raises: any probe error (an exotic platform, a permissions surprise) degrades
95
+ to None, so a packaging oddity can only LOSE the accelerator, never break the hook.
96
+ """
97
+ if not native_hook_enabled():
98
+ return None
99
+ try:
100
+ candidate = _BIN_DIR / bundled_binary_name()
101
+ if not candidate.is_file():
102
+ return None
103
+ # On POSIX the file must be executable to exec it; on Windows the bit is
104
+ # meaningless (an .exe is run by extension), so we don't gate on it there.
105
+ if os.name != "nt" and not os.access(candidate, os.X_OK):
106
+ return None
107
+ return candidate
108
+ except OSError:
109
+ return None
110
+
111
+
112
+ def hook_argv_from_args(args: object) -> list[str]:
113
+ """Rebuild the `dos hook <verb>` flag list from the parsed argparse namespace.
114
+
115
+ The native binary's CLI mirrors the Python verb's, so we re-emit the same flags it
116
+ understands: `--workspace`, `--dialect`, `--handler` (pretool), `--session-id`
117
+ (posttool), `--debug`. A flag absent / left at its argparse default is omitted (the
118
+ binary applies the same default). Unknown/None values are skipped, so a namespace
119
+ missing a flag (e.g. posttool has no --handler) simply doesn't emit it.
120
+ """
121
+ # The default-dialect name is imported, never spelled here: the vendor-blindness
122
+ # litmus allows the literal in hook_dialect.py ONLY (the one sanctioned default),
123
+ # and a kernel module must not branch on a vendor literal of its own.
124
+ from dos.hook_dialect import DEFAULT_DIALECT
125
+
126
+ out: list[str] = []
127
+ workspace = getattr(args, "workspace", None)
128
+ if workspace:
129
+ out += ["--workspace", str(workspace)]
130
+ dialect = getattr(args, "dialect", None)
131
+ # Only forward a NON-default dialect — the binary's default matches the kernel's,
132
+ # so omitting it keeps the argv minimal and the native default authoritative.
133
+ if dialect and dialect != DEFAULT_DIALECT:
134
+ out += ["--dialect", str(dialect)]
135
+ handler = getattr(args, "handler", None)
136
+ if handler and handler != "observe":
137
+ out += ["--handler", str(handler)]
138
+ session_id = getattr(args, "session_id", None)
139
+ if session_id:
140
+ out += ["--session-id", str(session_id)]
141
+ if getattr(args, "debug", False):
142
+ out += ["--debug"]
143
+ return out
144
+
145
+
146
+ def try_native_hook(verb: str, argv: list[str]) -> int | None:
147
+ """Run the bundled native binary for `verb` if one is present; else return None.
148
+
149
+ The "consult and fall back" pre-amble the CLI hook verbs call BEFORE reading stdin:
150
+
151
+ * Returns an `int` exit code when the native binary OWNED the decision (it has
152
+ already emitted any host dialect to this process's stdout and exited 0) — the
153
+ CLI returns that code directly, the native fast path.
154
+ * Returns `None` when there is no usable native path — no bundled binary for this
155
+ platform, the `DOS_HOOK_NATIVE=0` opt-out, a verb the binary does not serve
156
+ natively (`stop`/`marker`), a DELEGATE (exit 3) sentinel, or ANY launch error.
157
+ The CLI then runs its in-process Python decider (the docs/100 fallback).
158
+
159
+ `verb` is the hook verb (`pretool`/`posttool`); `argv` is the flags AFTER the verb
160
+ (e.g. `["--workspace", "."]`) — the native binary's CLI mirrors `dos hook <verb>
161
+ <flags>`. stdin is inherited so the binary reads the SAME event the Python body
162
+ would; stdout is inherited so its dialect reaches the host unbuffered through us.
163
+
164
+ Never raises: a missing binary, a launch failure, or a crash all degrade to None
165
+ (run Python), so the accelerator can only be SKIPPED, never break the hook.
166
+ """
167
+ if verb not in NATIVE_HOOK_VERBS:
168
+ return None
169
+ binary = native_hook_binary()
170
+ if binary is None:
171
+ return None
172
+ try:
173
+ # Inherit stdin (the event) + stdout (the dialect) + stderr (--debug) so the
174
+ # native binary's I/O is byte-identical to the Python body's, and we add no
175
+ # buffering between it and the host runtime.
176
+ proc = subprocess.run(
177
+ [str(binary), verb, *argv],
178
+ stdin=sys.stdin,
179
+ stdout=sys.stdout,
180
+ stderr=sys.stderr,
181
+ )
182
+ except OSError:
183
+ # The binary vanished between the is_file() probe and exec, or the OS refused
184
+ # to launch it — fall through to Python.
185
+ return None
186
+ if proc.returncode == DELEGATE_EXIT:
187
+ return None # the binary punted this one to Python
188
+ if proc.returncode != 0:
189
+ # An abnormal exit (a panic the binary's own recover() did not catch, a signal):
190
+ # do NOT trust a partial native decision — but stdin is now consumed, so the
191
+ # Python body can't re-decide. The hook fail-safe is exit 0 / emit-nothing, and
192
+ # the binary will have emitted nothing on a crash, so 0 is the safe report.
193
+ return 0
194
+ return 0 # the native binary owned it cleanly
dos/hook_dialect.py ADDED
@@ -0,0 +1,271 @@
1
+ """hook_dialect — render a DOS PRE/POST/STOP verdict into the bytes a host honors.
2
+
3
+ > **The verdict is the kernel; the envelope is a driver (docs/217).** DOS computes
4
+ > ONE dialect-neutral hook decision (deny / warn / pass) and renders it into the
5
+ > exact JSON the *host runtime* parses — Claude Code today, Gemini CLI / Codex CLI /
6
+ > Cursor next. This is the third instance of the kernel's pure-protocol +
7
+ > by-name-resolver pattern, after `dos.judges` (the JUDGE rung) and
8
+ > `dos.overlap_policy` (the disjointness scorer) — here on the OUTPUT side.
9
+
10
+ Why this module exists
11
+ ======================
12
+
13
+ `pretool_sensor.decide()` / `posttool_sensor.warn_payload()` already emit the exact
14
+ **Claude Code** `hookSpecificOutput` envelope (the one real CC honors, verified
15
+ against the CC source). But the other agent runtimes ship their OWN deny-capable
16
+ pre-tool hook with a DIFFERENT envelope:
17
+
18
+ * **Gemini CLI** (`BeforeTool`/`AfterTool`): `{"decision": "deny", "reason": …}`
19
+ * **Codex CLI** (`PreToolUse`/`PostToolUse`): CC-identical `hookSpecificOutput`
20
+ * **Cursor** (`beforeShellExecution`/…): `{"permission": "deny"|"allow", …}`
21
+
22
+ Point `dos hook pretool` at any of those today and it emits the CC envelope they do
23
+ NOT parse — a SILENT no-op (the original `dos hook stop`-vs-CC bug). This module
24
+ closes that: a host-selected renderer turns the verdict into the right bytes.
25
+
26
+ The neutral form is the Claude-Code dict
27
+ ========================================
28
+
29
+ `decide()` already returns the CC dict, and that dict is **lossless** for every
30
+ target host: a deny carries `permissionDecisionReason` (+ optional
31
+ `additionalContext`), a warn carries `additionalContext`, a pass is `None`. So
32
+ rather than re-plumb `decide()`'s four return sites (and churn the 67 green hook
33
+ tests that assert its CC shape), we treat the CC dict as the canonical internal
34
+ form and TRANSCODE it: `parse_cc(cc_dict) -> HookVerdict`, then
35
+ `dialect.render(verdict) -> host_dict`. The `ClaudeCodeDialect` round-trips to the
36
+ SAME bytes `decide()` already produced (so `--dialect claude-code`, the default, is
37
+ byte-for-byte today's behavior — the docs/217 Phase-1 gate).
38
+
39
+ Discipline (inherited from docs/191 §4, the byte-author floor)
40
+ ==============================================================
41
+
42
+ NO dialect ever emits a tool-input REWRITE key (`updatedInput` / `updated_input`;
43
+ Cursor's `preToolUse` *can* rewrite input — DOS must NOT). A verdict carries only a
44
+ `reason` (operator-facing why) and a `context` (a fact to RE-SURFACE) — never a
45
+ corrective ARGUMENT minted for the agent. Rendering is PURE: a verdict in, a dict
46
+ out, no I/O — the same line `judges`/`overlap_policy` draw around their seams.
47
+
48
+ A wrong dialect name fails LOUD, not silent
49
+ ===========================================
50
+
51
+ `resolve_dialect("typo")` RAISES. Unlike `judges` (fail-to-abstain) and
52
+ `overlap_policy` (fail-to-floor), whose fallbacks are the SAFE direction, a dialect
53
+ fallback is NOT safe: a host that asked for `cursor` and silently got `claude-code`
54
+ emits a no-op against Cursor — the exact failure this module exists to prevent. So
55
+ an unknown dialect is an operator error surfaced immediately, never papered over.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import enum
61
+ from dataclasses import dataclass
62
+ from typing import Optional, Protocol, runtime_checkable
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # The dialect-neutral verdict — what the PEP decided, with no envelope grammar.
67
+ # ---------------------------------------------------------------------------
68
+ class HookMoment(enum.Enum):
69
+ """Which lifecycle seam the verdict is for (selects the host's event name)."""
70
+
71
+ PRE = "pre" # before a tool runs — the only moment a deny is honored
72
+ POST = "post" # after a tool ran — context-only (cannot block)
73
+ STOP = "stop" # the agent wants to stop — refuse a false done
74
+
75
+
76
+ class HookAction(enum.Enum):
77
+ """The dialect-neutral decision. Maps 1:1 onto every host's grammar."""
78
+
79
+ DENY = "deny" # withhold the call / refuse the stop
80
+ WARN = "warn" # add context, do NOT block (turn-preserving)
81
+ PASS = "pass" # emit nothing
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class HookVerdict:
86
+ """A host-free description of a PRE/POST/STOP decision. PURE data.
87
+
88
+ `reason` — the operator-facing why (carried on DENY and WARN).
89
+ `context` — a fact to RE-SURFACE to the agent (a WARN's whole payload, or the
90
+ corrective surfaced alongside a provenance DENY). NEVER a rewritten
91
+ tool argument (the docs/191 §4 byte-author floor).
92
+ """
93
+
94
+ moment: HookMoment
95
+ action: HookAction
96
+ reason: str = ""
97
+ context: str = ""
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Transcode the canonical CC dict (what decide()/warn_payload already return)
102
+ # into the neutral verdict. PURE.
103
+ # ---------------------------------------------------------------------------
104
+ def parse_cc(cc_dict: Optional[dict], *, moment: HookMoment) -> HookVerdict:
105
+ """Read a Claude-Code hook dict (or None) into a `HookVerdict`. PURE, total.
106
+
107
+ `None` (or any unparseable shape) → a PASS verdict (emit nothing) — the
108
+ fail-to-passthrough direction the sensors already take. A `permissionDecision:
109
+ deny` → DENY (reason + any `additionalContext` as context). An
110
+ `additionalContext` with NO `permissionDecision` → WARN (context = that text).
111
+ """
112
+ if not isinstance(cc_dict, dict):
113
+ return HookVerdict(moment=moment, action=HookAction.PASS)
114
+ hso = cc_dict.get("hookSpecificOutput")
115
+ if not isinstance(hso, dict):
116
+ return HookVerdict(moment=moment, action=HookAction.PASS)
117
+ decision = hso.get("permissionDecision")
118
+ context = hso.get("additionalContext")
119
+ context = context if isinstance(context, str) else ""
120
+ if decision == "deny":
121
+ reason = hso.get("permissionDecisionReason")
122
+ reason = reason if isinstance(reason, str) else ""
123
+ return HookVerdict(moment=moment, action=HookAction.DENY, reason=reason, context=context)
124
+ if context:
125
+ # additionalContext present, no deny → a WARN (turn-preserving re-surface).
126
+ return HookVerdict(moment=moment, action=HookAction.WARN, context=context)
127
+ return HookVerdict(moment=moment, action=HookAction.PASS)
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # The dialect Protocol + the four built-in renderers. Each is PURE: verdict in,
132
+ # host dict (or None for PASS) out. NO I/O, NO tool-input rewrite key.
133
+ # ---------------------------------------------------------------------------
134
+ @runtime_checkable
135
+ class HookDialect(Protocol):
136
+ name: str
137
+
138
+ def render(self, verdict: HookVerdict) -> Optional[dict]:
139
+ """The host's envelope for this verdict, or None to emit nothing (PASS)."""
140
+ ...
141
+
142
+
143
+ _CC_EVENT = {HookMoment.PRE: "PreToolUse", HookMoment.POST: "PostToolUse", HookMoment.STOP: "Stop"}
144
+
145
+
146
+ class ClaudeCodeDialect:
147
+ """The DEFAULT — byte-for-byte what `decide()`/`warn_payload` already emit.
148
+
149
+ A round-trip floor: `render(parse_cc(d))` reproduces `d` for the deny/warn/pass
150
+ cases the sensors produce, so `--dialect claude-code` is today's behavior exactly
151
+ (the docs/217 Phase-1 gate, pinned by the existing 67-test hook suite + a
152
+ round-trip test here).
153
+ """
154
+
155
+ name = "claude-code"
156
+
157
+ def render(self, verdict: HookVerdict) -> Optional[dict]:
158
+ if verdict.action is HookAction.PASS:
159
+ return None
160
+ event = _CC_EVENT[verdict.moment]
161
+ if verdict.action is HookAction.DENY:
162
+ hso = {
163
+ "hookEventName": event,
164
+ "permissionDecision": "deny",
165
+ "permissionDecisionReason": verdict.reason,
166
+ }
167
+ if verdict.context:
168
+ hso["additionalContext"] = verdict.context
169
+ return {"hookSpecificOutput": hso}
170
+ # WARN — additionalContext only, no permissionDecision (passthrough).
171
+ return {"hookSpecificOutput": {"hookEventName": event, "additionalContext": verdict.context}}
172
+
173
+
174
+ # The non-default, vendor-NAMED renderers (`codex` / `gemini` / `cursor`) used to
175
+ # live here, but a renderer must name its vendor as CODE (a `GeminiDialect` is
176
+ # inherently Gemini-specific), which the vendor-blindness litmus forbids in a kernel
177
+ # module (`tests/test_vendor_agnostic_kernel.py`). So — per docs/217's own thesis
178
+ # ("the envelope is a driver") and the judges/overlap_policy precedent — they moved
179
+ # to `dos.drivers.hook_dialects` and register through the `dos.hook_dialects`
180
+ # entry-point group. `resolve_dialect("gemini")` discovers them by name at the call
181
+ # boundary; the kernel seam imports no vendor renderer. The ONE built-in that stays
182
+ # is `ClaudeCodeDialect`: the unshadowable default (byte-for-byte what the sensors
183
+ # already emit), the analogue of `AbstainJudge` — a deterministic baseline the kernel
184
+ # always has even with zero drivers installed.
185
+
186
+ # Singleton (stateless).
187
+ _CLAUDE_CODE = ClaudeCodeDialect()
188
+
189
+ #: The built-in dialects, by name — just the unshadowable default. Every other host
190
+ #: renderer is a `dos.hook_dialects` plugin (the kernel names no other vendor as code).
191
+ BUILTIN_DIALECTS: dict[str, HookDialect] = {
192
+ _CLAUDE_CODE.name: _CLAUDE_CODE,
193
+ }
194
+
195
+ #: The default — the host DOS has spoken since the hooks shipped.
196
+ DEFAULT_DIALECT = "claude-code"
197
+
198
+
199
+ def resolve_dialect(name: Optional[str]) -> HookDialect:
200
+ """Resolve a dialect by name. RAISES on an unknown name (fail-LOUD).
201
+
202
+ `None`/empty → the default (`claude-code`). A built-in name → that renderer. A
203
+ `dos.hook_dialects` entry-point name → the discovered plugin. An UNKNOWN name →
204
+ `ValueError` (NEVER a silent CC fallback — a wrong dialect against a non-CC host
205
+ is the no-op bug this seam prevents).
206
+ """
207
+ if not name:
208
+ return BUILTIN_DIALECTS[DEFAULT_DIALECT]
209
+ if name in BUILTIN_DIALECTS:
210
+ return BUILTIN_DIALECTS[name]
211
+ plugin = _load_plugin_dialect(name)
212
+ if plugin is not None:
213
+ return plugin
214
+ known = ", ".join(sorted(BUILTIN_DIALECTS))
215
+ raise ValueError(
216
+ f"unknown hook dialect {name!r} — known: {known} "
217
+ f"(or a registered dos.hook_dialects plugin). Refusing to fall back to "
218
+ f"{DEFAULT_DIALECT!r}: a wrong dialect against a non-Claude-Code host is a "
219
+ f"silent no-op."
220
+ )
221
+
222
+
223
+ def available_dialects() -> list[str]:
224
+ """The names a host may pass to `--dialect` — built-ins + discovered plugins."""
225
+ names = set(BUILTIN_DIALECTS)
226
+ try:
227
+ names.update(_plugin_dialect_names())
228
+ except Exception:
229
+ pass
230
+ return sorted(names)
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Plugin discovery (boundary I/O — at resolve time, never inside render). The
235
+ # `dos.hook_dialects` entry-point group, the same mechanism as dos.judges /
236
+ # dos.overlap_policies. Kept defensive: a broken plugin never breaks resolution of
237
+ # a built-in.
238
+ # ---------------------------------------------------------------------------
239
+ def _iter_entry_points():
240
+ try:
241
+ from importlib.metadata import entry_points
242
+ except Exception: # pragma: no cover - very old Python
243
+ return []
244
+ try:
245
+ eps = entry_points()
246
+ # Python 3.10+ selectable API, with a 3.9 dict fallback.
247
+ if hasattr(eps, "select"):
248
+ return list(eps.select(group="dos.hook_dialects"))
249
+ return list(eps.get("dos.hook_dialects", [])) # type: ignore[attr-defined]
250
+ except Exception:
251
+ return []
252
+
253
+
254
+ def _plugin_dialect_names() -> list[str]:
255
+ return [ep.name for ep in _iter_entry_points()]
256
+
257
+
258
+ def _load_plugin_dialect(name: str) -> Optional[HookDialect]:
259
+ for ep in _iter_entry_points():
260
+ if ep.name != name:
261
+ continue
262
+ try:
263
+ obj = ep.load()
264
+ inst = obj() if isinstance(obj, type) else obj
265
+ except Exception:
266
+ return None
267
+ # Duck-typed: must have a callable render + a name (the Protocol surface).
268
+ if hasattr(inst, "render") and callable(inst.render):
269
+ return inst # type: ignore[return-value]
270
+ return None
271
+ return None
dos/hook_exit.py ADDED
@@ -0,0 +1,191 @@
1
+ """HEX — the hook exit-code classifier: *a plain shell script's exit code → an intervention verb.*
2
+
3
+ docs/226 — idea **C3** from the Claude Code source audit (docs/189). The cheapest
4
+ possible integration surface: a host has a shell script that checks something (a
5
+ linter, a policy probe, a smoke test) and signals its result the only way a plain
6
+ process can — an **exit code**. CC already gives this a meaning
7
+ (`src/utils/hooks.ts`): a command hook's `exit 0` is success (proceed), `exit 2` is
8
+ a *blocking error* (stop the action), and any other non-zero is a non-blocking error
9
+ (a warning that still proceeds). It is the same convention `git` hooks and
10
+ `pre-commit` use — universal, zero-ceremony, no JSON parser required.
11
+
12
+ DOS has rich hook adapters (`pretool_sensor`, `posttool_sensor`) that read the CC
13
+ JSON dialect, and a closed intervention vocabulary (`intervention.Intervention`:
14
+ OBSERVE‹WARN‹BLOCK‹DEFER). What it lacked was the *bridge* between the two for the
15
+ **unsophisticated** integration: "I just have a shell script that exits 2 — turn
16
+ that into a DOS intervention." This module is that bridge — a pure map from an exit
17
+ code to an `Intervention` verb.
18
+
19
+ It is the `liveness`/`productivity`/`breaker` shape — a pure verdict over
20
+ already-gathered state — for a different input:
21
+
22
+ liveness.classify (ProgressEvidence, policy) -> LivenessVerdict
23
+ productivity.classify (WorkHistory, policy) -> ProductivityVerdict
24
+ breaker.classify (BreakerCounts, policy) -> BreakerVerdict
25
+ hook_exit.classify_exit (code, policy) -> ExitVerdict
26
+ ^ THIS module
27
+
28
+ **Mechanism vs policy — the malloc split.** The mechanism is "look up the exit code
29
+ in a map." The policy — *which code means which verb* — is data, defaulted to CC's
30
+ convention (`0 → pass`, `2 → BLOCK`, other-nonzero → WARN) and declarable
31
+ per-workspace in `dos.toml [hook_exit]`. A host that wants `exit 3 = DEFER`, or
32
+ `exit 0 = OBSERVE` (record even on success), changes one line of data; the kernel's
33
+ lookup never changes. The classifier never knows what the script *did* — only the
34
+ code it returned. That is what makes it a universal cog: it is the `malloc` of
35
+ shell-hook integration, mechanism with the script's domain pushed entirely out.
36
+
37
+ **Why a script's exit code is sound evidence here.** The exit code is authored by
38
+ the *script process*, not by the judged agent — it is the script's verdict on the
39
+ agent's action, exactly the actor-witness split (docs/117): the byte-author (the
40
+ script) is not the judged party (the agent). So `classify_exit` reads an
41
+ agent-external signal, the same discipline `liveness` (git) and `exec_capability`
42
+ (the command shape) follow. The script is a deterministic JUDGE on the trust ladder;
43
+ this module routes its terse verdict into the kernel's vocabulary.
44
+
45
+ **Advisory, fail-safe.** The verdict RECOMMENDS an intervention; like every verb in
46
+ this family it never acts. And the safe-failure direction is baked into the default
47
+ map: an *unknown* non-zero code degrades to WARN (inform, do not block) — never to a
48
+ silent pass and never to a spurious BLOCK. A script that fails in a way the host did
49
+ not anticipate surfaces as a warning, the docs/143 −9 pp posture (a wrong BLOCK is
50
+ the expensive mistake; a WARN is cheap).
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from dataclasses import dataclass, field
56
+ from typing import Optional
57
+
58
+ from dos.intervention import Intervention
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # The default convention — CC's `src/utils/hooks.ts` exit-code semantics, lifted
63
+ # verbatim, as data. `0` = proceed (no intervention); `2` = blocking error
64
+ # (BLOCK); any OTHER non-zero = non-blocking error (WARN, the fallback). Declared
65
+ # as a map so a workspace overrides it in `dos.toml [hook_exit]` — the
66
+ # closed-config-as-data pattern (`[lanes]`/`[reasons]`/`[exec_capability]`).
67
+ # ---------------------------------------------------------------------------
68
+ # The codes that map to a SPECIFIC verb. `0` is the special "pass" code (no
69
+ # intervention) — represented as None in the map so it is distinct from OBSERVE
70
+ # (OBSERVE records a verdict; PASS records nothing and proceeds). Every other
71
+ # non-zero code not named here falls to `fallback`.
72
+ _CC_EXIT_MAP: dict[int, Optional[Intervention]] = {
73
+ 0: None, # success — proceed, no intervention (the PASS code)
74
+ 2: Intervention.BLOCK, # blocking error — CC's `exit 2` (the load-bearing one)
75
+ }
76
+ _CC_FALLBACK = Intervention.WARN # any other non-zero — non-blocking error → inform
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class HookExitPolicy:
81
+ """The exit-code → intervention map — policy, not mechanism.
82
+
83
+ The same "mechanism is kernel, the map is config" split as `liveness`'s windows
84
+ and `breaker`'s thresholds. Defaults to CC's convention; a workspace declares its
85
+ own in `dos.toml [hook_exit]`, e.g. `3 = "DEFER"`, `0 = "OBSERVE"`.
86
+
87
+ pass_code — the exit code that means "proceed, no intervention" (default 0).
88
+ The script approved; nothing to actuate.
89
+ mapping — explicit `{code: Intervention}` for codes that map to a verb.
90
+ A code present here with value None is also treated as PASS (a
91
+ host can declare multiple success codes).
92
+ fallback — the verb for any non-zero code NOT in `mapping` and not the
93
+ `pass_code` (default WARN — inform, the fail-safe direction; never
94
+ a silent pass, never a spurious BLOCK on an unanticipated code).
95
+ """
96
+
97
+ pass_code: int = 0
98
+ mapping: dict[int, Intervention] = field(
99
+ default_factory=lambda: {2: Intervention.BLOCK}
100
+ )
101
+ fallback: Intervention = _CC_FALLBACK
102
+
103
+ def with_mapping(self, more: dict) -> "HookExitPolicy":
104
+ """A new policy with `more` `{code: Intervention}` entries merged in (host on-ramp)."""
105
+ merged = dict(self.mapping)
106
+ for code, verb in (more or {}).items():
107
+ merged[int(code)] = verb if isinstance(verb, Intervention) else Intervention(str(verb))
108
+ return HookExitPolicy(pass_code=self.pass_code, mapping=merged, fallback=self.fallback)
109
+
110
+
111
+ DEFAULT_POLICY = HookExitPolicy()
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class ExitVerdict:
116
+ """The classifier's verdict: the intervention an exit code maps to (None = PASS).
117
+
118
+ `intervention` is the `Intervention` the code maps to, or None when the code is
119
+ the pass code (proceed, nothing to actuate — distinct from OBSERVE, which records
120
+ a verdict). `code` is the exit code classified (echoed for the JSON consumer).
121
+ `reason` is the one-line operator-facing summary. `matched` says whether the code
122
+ was an EXPLICIT map entry (True) or fell to the fallback (False) — legible
123
+ distrust: the consumer can tell a declared mapping from the catch-all.
124
+ """
125
+
126
+ code: int
127
+ intervention: Optional[Intervention]
128
+ reason: str
129
+ matched: bool
130
+
131
+ @property
132
+ def passed(self) -> bool:
133
+ """True iff the script approved — proceed, no intervention."""
134
+ return self.intervention is None
135
+
136
+ def to_dict(self) -> dict:
137
+ return {
138
+ "code": self.code,
139
+ "intervention": self.intervention.value if self.intervention else None,
140
+ "reason": self.reason,
141
+ "matched": self.matched,
142
+ }
143
+
144
+
145
+ def classify_exit(
146
+ code: int, policy: HookExitPolicy = DEFAULT_POLICY
147
+ ) -> ExitVerdict:
148
+ """Map a hook script's exit code → an intervention verb. PURE — no I/O.
149
+
150
+ The ladder:
151
+ 1. PASS — `code == policy.pass_code` (default 0): the script approved. Proceed,
152
+ no intervention (`intervention=None`). The success case.
153
+ 2. MAPPED — `code` is an explicit `policy.mapping` entry: the declared verb
154
+ (default `2 → BLOCK`, CC's blocking-error code). `matched=True`.
155
+ 3. FALLBACK — any other non-zero code: `policy.fallback` (default WARN). The
156
+ fail-safe catch-all — an unanticipated failure informs, never silently
157
+ passes and never spuriously blocks. `matched=False`.
158
+
159
+ `code` is the integer a host captured from `subprocess`/`$?`. The classifier
160
+ reads only the code — never the script's stdout/stderr or what it did (that is
161
+ the script's domain, pushed out; the kernel maps the terse signal).
162
+ """
163
+ # A code explicitly mapped to None (a host's extra success code) is also PASS.
164
+ if code == policy.pass_code or (code in policy.mapping and policy.mapping[code] is None):
165
+ return ExitVerdict(
166
+ code=code,
167
+ intervention=None,
168
+ reason=f"exit {code} — the hook script approved; proceed (no intervention)",
169
+ matched=code in policy.mapping or code == policy.pass_code,
170
+ )
171
+ verb = policy.mapping.get(code)
172
+ if verb is not None:
173
+ return ExitVerdict(
174
+ code=code,
175
+ intervention=verb,
176
+ reason=(
177
+ f"exit {code} → {verb.value} (a declared hook-exit mapping"
178
+ + (" — CC's blocking-error code)" if code == 2 and verb is Intervention.BLOCK
179
+ else ")")
180
+ ),
181
+ matched=True,
182
+ )
183
+ return ExitVerdict(
184
+ code=code,
185
+ intervention=policy.fallback,
186
+ reason=(
187
+ f"exit {code} → {policy.fallback.value} (a non-zero code with no declared "
188
+ f"mapping — the fail-safe fallback: inform, never silently pass or block)"
189
+ ),
190
+ matched=False,
191
+ )