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
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
|
+
)
|