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_install.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""hook_install — wire the DOS hooks into a host runtime's OWN config file.
|
|
2
|
+
|
|
3
|
+
> **The verdict is the kernel; the envelope is a driver (docs/217); the WIRING is
|
|
4
|
+
> an installer (docs/221).** `hook_dialect.py` renders a DOS deny/observe verdict
|
|
5
|
+
> into the bytes a host honors. This module owns the *other* host fact: WHERE and
|
|
6
|
+
> in WHAT FORMAT that host registers a hook command — the config-file path
|
|
7
|
+
> (`.cursor/hooks.json` vs `.codex/config.toml` vs …), the file format (JSON vs
|
|
8
|
+
> TOML), and the host's event-name vocabulary. So `dos init --hooks <host>` can
|
|
9
|
+
> bind the DOS PEP into a team's existing runtime with no hand-authored config.
|
|
10
|
+
|
|
11
|
+
The kernel/driver split — the SAME line `hook_dialect` draws
|
|
12
|
+
============================================================
|
|
13
|
+
|
|
14
|
+
This module is the install-side sibling of `hook_dialect`, and it obeys the same
|
|
15
|
+
litmus (`tests/test_vendor_agnostic_kernel.py`): **no non-driver kernel module names
|
|
16
|
+
a vendor as a code identifier**, so no kernel *adjudication* can branch on which
|
|
17
|
+
vendor is acting. Therefore:
|
|
18
|
+
|
|
19
|
+
* the KERNEL (this module) holds the pure, vendor-blind machinery — the
|
|
20
|
+
`HostHookSpec` TYPE, the merge algorithms (`merge_json` / `merge_toml`),
|
|
21
|
+
and the ONE unshadowable baseline `claude_code_spec()` (the `ClaudeCodeDialect`
|
|
22
|
+
analogue: DOS's own sensors emit a Claude-Code-shaped command, so it is the
|
|
23
|
+
default, not an adjudication branch);
|
|
24
|
+
* every OTHER host's install-facts — the rows that must name `cursor`/`codex`/
|
|
25
|
+
`gemini` as code — live in a DRIVER (`drivers/hook_dialects.py`, co-located with
|
|
26
|
+
the dialect renderers they pair with), discovered by name through the
|
|
27
|
+
`dos.hook_installs` entry-point group, exactly as `resolve_dialect` discovers the
|
|
28
|
+
per-vendor renderers through `dos.hook_dialects`.
|
|
29
|
+
|
|
30
|
+
A host install-spec legitimately names its vendor — but it is OUTPUT wiring chosen
|
|
31
|
+
explicitly by the operator (`--hooks cursor`), strictly downstream of a decision the
|
|
32
|
+
kernel already made vendor-blind. That is precisely why it belongs on the driver
|
|
33
|
+
side of the line.
|
|
34
|
+
|
|
35
|
+
The pure core + the I/O boundary
|
|
36
|
+
================================
|
|
37
|
+
|
|
38
|
+
The `HostHookSpec` table and the in-memory merge functions (`merge_json` /
|
|
39
|
+
`merge_toml`) are PURE. The file READ/PARSE/WRITE lives at the CLI boundary
|
|
40
|
+
(`cli.py:cmd_init`) — the "I/O at the boundary, data to the pure core" rule the
|
|
41
|
+
kernel rests on (`git_delta`/`journal_delta` → `liveness.classify`).
|
|
42
|
+
|
|
43
|
+
A wrong host fails LOUD, not silent
|
|
44
|
+
===================================
|
|
45
|
+
|
|
46
|
+
`host_spec("typo")` RAISES (like `hook_dialect.resolve_dialect`). A host that asked
|
|
47
|
+
for `cursor` and silently got the Claude-Code file would wire a no-op deny against
|
|
48
|
+
Cursor — the exact failure docs/217/221 exist to prevent.
|
|
49
|
+
|
|
50
|
+
Facts as of 2026-06-07
|
|
51
|
+
======================
|
|
52
|
+
|
|
53
|
+
Event names and config shapes were web-grounded on each runtime's then-current hook
|
|
54
|
+
docs (docs/221 §1a). They churn every minor release; a host moving is a one-line
|
|
55
|
+
edit to its `HostHookSpec` row in the driver — never a change to this kernel module.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import enum
|
|
61
|
+
from dataclasses import dataclass
|
|
62
|
+
from typing import Optional
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# The DOS verbs wired per lifecycle moment — the same three SHIPPED hooks the
|
|
67
|
+
# Claude-Code installer wires (cli.py:_DOS_HOOK_COMMANDS), named once here so every
|
|
68
|
+
# host's spec maps its events onto them. `pretool` DENIES a structurally-refused
|
|
69
|
+
# call before it runs; `posttool` re-surfaces a stalled stream (advisory); `stop`
|
|
70
|
+
# refuses a stop on an unverified claim.
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
PRE_VERB = "dos hook pretool"
|
|
73
|
+
POST_VERB = "dos hook posttool"
|
|
74
|
+
STOP_VERB = "dos hook stop"
|
|
75
|
+
|
|
76
|
+
#: The marker every DOS-wired command carries, so a re-run can find its own prior
|
|
77
|
+
#: entry and stay idempotent (the generalization of `_is_dos_hook_group`'s rule).
|
|
78
|
+
DOS_COMMAND_PREFIX = "dos hook "
|
|
79
|
+
|
|
80
|
+
#: The fence around the DOS block in a host's TOML config (Codex). A re-run is
|
|
81
|
+
#: idempotent on the OPENING marker; the block is appended verbatim, never
|
|
82
|
+
#: re-serialized (so the user's comments/keys survive — see merge_toml).
|
|
83
|
+
TOML_FENCE_OPEN = "# >>> dos hooks (managed by `dos init --hooks`) >>>"
|
|
84
|
+
TOML_FENCE_CLOSE = "# <<< dos hooks <<<"
|
|
85
|
+
|
|
86
|
+
#: The default host — the one DOS has spoken since the hooks shipped, and the only
|
|
87
|
+
#: spec that lives in the kernel (the `DEFAULT_DIALECT` analogue). Its `--dialect` is
|
|
88
|
+
#: implicit, so its wired command is byte-identical to today's `--with-hooks`.
|
|
89
|
+
DEFAULT_HOST = "claude-code"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConfigFormat(enum.Enum):
|
|
93
|
+
"""How the host's hook-config file is encoded."""
|
|
94
|
+
|
|
95
|
+
JSON = "json" # Claude Code, Cursor, Gemini
|
|
96
|
+
TOML = "toml" # Codex
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class HostHookSpec:
|
|
101
|
+
"""Everything `dos init --hooks <host>` needs to wire one runtime. PURE data.
|
|
102
|
+
|
|
103
|
+
`host` — the `--hooks` value, AND the `--dialect` name the wired command
|
|
104
|
+
carries (so the verb emits the right envelope). For the default
|
|
105
|
+
host the dialect is implicit and is OMITTED, keeping the command
|
|
106
|
+
byte-identical to today's `--with-hooks`.
|
|
107
|
+
`dialect_flag` — the exact `--dialect …` suffix appended to each wired command,
|
|
108
|
+
or "" for the default host. Carried as DATA so `command_for`
|
|
109
|
+
never compares against a vendor literal (the vendor-agnostic-
|
|
110
|
+
kernel litmus): the host's identity rides a data field, not a
|
|
111
|
+
branch.
|
|
112
|
+
`config_path` — path PARTS under the workspace root (joined at the boundary):
|
|
113
|
+
`(".cursor", "hooks.json")`.
|
|
114
|
+
`fmt` — JSON or TOML.
|
|
115
|
+
`pre/post/stop_events` — the host's event name(s) for each DOS moment. A moment
|
|
116
|
+
may map to MORE THAN ONE host event (Cursor's PRE is
|
|
117
|
+
shell+MCP); each event gets the same DOS command.
|
|
118
|
+
`json_entry_has_type` — JSON hosts only: does an entry carry a `"type":
|
|
119
|
+
"command"` key (CC, Gemini, Codex-shape) or is it a flat
|
|
120
|
+
`{"command": …}` (Cursor)?
|
|
121
|
+
`json_group_wraps` — JSON hosts only: is each event a list of GROUPS each
|
|
122
|
+
`{"hooks": [entry,…]}` (Claude Code) or a flat list of entries
|
|
123
|
+
(Cursor, Gemini)?
|
|
124
|
+
`json_version` — JSON hosts only: a top-level `{"version": N}` the file
|
|
125
|
+
requires (Cursor needs `1`); `None` for none.
|
|
126
|
+
`note` — a one-line operator hint printed after wiring (e.g. Cursor's
|
|
127
|
+
`failClosed` option, Codex's handler-coverage limit).
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
host: str
|
|
131
|
+
config_path: tuple[str, ...]
|
|
132
|
+
fmt: ConfigFormat
|
|
133
|
+
pre_events: tuple[str, ...]
|
|
134
|
+
post_events: tuple[str, ...]
|
|
135
|
+
stop_events: tuple[str, ...]
|
|
136
|
+
dialect_flag: str = ""
|
|
137
|
+
json_entry_has_type: bool = True
|
|
138
|
+
json_group_wraps: bool = False
|
|
139
|
+
json_version: Optional[int] = None
|
|
140
|
+
note: str = ""
|
|
141
|
+
|
|
142
|
+
def command_for(self, verb: str) -> str:
|
|
143
|
+
"""The exact `dos hook …` command string this host wires for `verb`.
|
|
144
|
+
|
|
145
|
+
Appends `--workspace .` (as the CC installer does) and this host's
|
|
146
|
+
`dialect_flag` (empty for the default host → byte-identical to today's
|
|
147
|
+
`--with-hooks`). Reads the flag as DATA — it never compares `self.host`
|
|
148
|
+
against a vendor literal (the vendor-agnostic-kernel discipline). Every hook
|
|
149
|
+
verb (`pretool`/`posttool`/`stop`) accepts `--dialect`, so the suffix is
|
|
150
|
+
appended uniformly (docs/268: the `stop` verb gained `--dialect` so its
|
|
151
|
+
refusal renders in the host's shape — Cursor's `{"permission":…}`, Gemini's
|
|
152
|
+
`{"decision":…}` — not only CC's `{"decision":"block"}`).
|
|
153
|
+
"""
|
|
154
|
+
cmd = f"{verb} --workspace ."
|
|
155
|
+
if self.dialect_flag:
|
|
156
|
+
cmd += f" {self.dialect_flag}"
|
|
157
|
+
return cmd
|
|
158
|
+
|
|
159
|
+
def events_and_commands(self) -> list[tuple[str, str]]:
|
|
160
|
+
"""Every (host_event, dos_command) pair this host wires, in a stable order."""
|
|
161
|
+
pairs: list[tuple[str, str]] = []
|
|
162
|
+
for ev in self.pre_events:
|
|
163
|
+
pairs.append((ev, self.command_for(PRE_VERB)))
|
|
164
|
+
for ev in self.post_events:
|
|
165
|
+
pairs.append((ev, self.command_for(POST_VERB)))
|
|
166
|
+
for ev in self.stop_events:
|
|
167
|
+
pairs.append((ev, self.command_for(STOP_VERB)))
|
|
168
|
+
return pairs
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# The ONE built-in spec: the default host (the `ClaudeCodeDialect` analogue). It is
|
|
173
|
+
# the kernel's unshadowable baseline — DOS's own sensors emit its command shape — so
|
|
174
|
+
# it names the default as DATA in a frozen literal, never as a code identifier or a
|
|
175
|
+
# branch. Every OTHER host's spec lives in the driver (see module docstring).
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
def claude_code_spec() -> HostHookSpec:
|
|
178
|
+
"""The default host's install-facts: `.claude/settings.json`, group-wrapped,
|
|
179
|
+
NO `--dialect` (the default) → byte-identical to today's `--with-hooks`."""
|
|
180
|
+
return HostHookSpec(
|
|
181
|
+
host=DEFAULT_HOST,
|
|
182
|
+
config_path=(".claude", "settings.json"),
|
|
183
|
+
fmt=ConfigFormat.JSON,
|
|
184
|
+
pre_events=("PreToolUse",),
|
|
185
|
+
post_events=("PostToolUse",),
|
|
186
|
+
stop_events=("Stop",),
|
|
187
|
+
dialect_flag="", # the default — implicit, so the command matches today.
|
|
188
|
+
json_entry_has_type=True,
|
|
189
|
+
json_group_wraps=True, # CC nests entries under {"hooks": [...]} matcher-groups.
|
|
190
|
+
json_version=None,
|
|
191
|
+
note="",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def host_names() -> list[str]:
|
|
196
|
+
"""The names a host may pass to `--hooks` — the default + discovered driver specs."""
|
|
197
|
+
names = {DEFAULT_HOST}
|
|
198
|
+
try:
|
|
199
|
+
names.update(_plugin_spec_names())
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
return sorted(names)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def host_spec(name: Optional[str]) -> HostHookSpec:
|
|
206
|
+
"""Resolve a host spec by name. RAISES on an unknown name (fail-LOUD).
|
|
207
|
+
|
|
208
|
+
`None`/the default name → the built-in baseline. A name registered under the
|
|
209
|
+
`dos.hook_installs` entry-point group → that driver's spec. An unknown name →
|
|
210
|
+
`ValueError` (NEVER a silent default fallback: a wrong host would write the wrong
|
|
211
|
+
file in the wrong format, a no-op deny against the real runtime — the
|
|
212
|
+
`hook_dialect.resolve_dialect` discipline).
|
|
213
|
+
"""
|
|
214
|
+
if name in (None, DEFAULT_HOST):
|
|
215
|
+
return claude_code_spec()
|
|
216
|
+
plugin = _load_plugin_spec(name)
|
|
217
|
+
if plugin is not None:
|
|
218
|
+
return plugin
|
|
219
|
+
known = ", ".join(host_names())
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"unknown hook host {name!r} — known: {known}. Refusing to guess: a wrong "
|
|
222
|
+
f"host would wire a no-op deny against your real runtime."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# JSON merge (Claude Code, Cursor, Gemini). PURE: a parsed dict in, a parsed dict
|
|
228
|
+
# out, plus the (wired, already) event lists. The file read/write is the caller's.
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
def _json_entry(spec: HostHookSpec, command: str) -> dict:
|
|
231
|
+
"""One host hook entry for `command`, in this host's JSON shape."""
|
|
232
|
+
entry: dict = {}
|
|
233
|
+
if spec.json_entry_has_type:
|
|
234
|
+
entry["type"] = "command"
|
|
235
|
+
entry["command"] = command
|
|
236
|
+
if spec.json_group_wraps:
|
|
237
|
+
# Claude Code nests the entry under a matcher-group {"hooks": [entry]}.
|
|
238
|
+
return {"hooks": [entry]}
|
|
239
|
+
return entry
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _group_has_dos_command(group: object) -> bool:
|
|
243
|
+
"""True if a Claude-Code matcher-group already runs a `dos hook …` command."""
|
|
244
|
+
if not isinstance(group, dict):
|
|
245
|
+
return False
|
|
246
|
+
for h in group.get("hooks", []):
|
|
247
|
+
cmd = h.get("command", "") if isinstance(h, dict) else ""
|
|
248
|
+
if isinstance(cmd, str) and cmd.strip().startswith(DOS_COMMAND_PREFIX):
|
|
249
|
+
return True
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _entry_is_dos_command(entry: object) -> bool:
|
|
254
|
+
"""True if a flat entry (Cursor/Gemini) already runs a `dos hook …` command."""
|
|
255
|
+
if not isinstance(entry, dict):
|
|
256
|
+
return False
|
|
257
|
+
cmd = entry.get("command", "")
|
|
258
|
+
return isinstance(cmd, str) and cmd.strip().startswith(DOS_COMMAND_PREFIX)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def merge_json(
|
|
262
|
+
existing: dict, spec: HostHookSpec, *, force: bool = False
|
|
263
|
+
) -> tuple[dict, list[str], list[str]]:
|
|
264
|
+
"""Add the DOS hooks to a parsed JSON hook-config. PURE.
|
|
265
|
+
|
|
266
|
+
Returns `(merged, wired, already)` — the new config object, the events newly
|
|
267
|
+
wired, and the events that already had a DOS entry (skipped — the idempotent
|
|
268
|
+
path). Every non-DOS key/hook the user has is preserved. `force` drops an
|
|
269
|
+
existing DOS entry and re-adds the canonical one (the repair path), mirroring
|
|
270
|
+
`cli.py:_install_hooks`.
|
|
271
|
+
"""
|
|
272
|
+
settings: dict = dict(existing) if isinstance(existing, dict) else {}
|
|
273
|
+
|
|
274
|
+
if spec.json_version is not None and "version" not in settings:
|
|
275
|
+
settings["version"] = spec.json_version
|
|
276
|
+
|
|
277
|
+
hooks = settings.get("hooks")
|
|
278
|
+
if not isinstance(hooks, dict):
|
|
279
|
+
hooks = {}
|
|
280
|
+
else:
|
|
281
|
+
hooks = dict(hooks)
|
|
282
|
+
settings["hooks"] = hooks
|
|
283
|
+
|
|
284
|
+
wired: list[str] = []
|
|
285
|
+
already: list[str] = []
|
|
286
|
+
has_dos = _group_has_dos_command if spec.json_group_wraps else _entry_is_dos_command
|
|
287
|
+
|
|
288
|
+
for event, command in spec.events_and_commands():
|
|
289
|
+
groups = hooks.get(event)
|
|
290
|
+
groups = list(groups) if isinstance(groups, list) else []
|
|
291
|
+
present = any(has_dos(g) for g in groups)
|
|
292
|
+
if present and not force:
|
|
293
|
+
already.append(event)
|
|
294
|
+
hooks[event] = groups
|
|
295
|
+
continue
|
|
296
|
+
if present and force:
|
|
297
|
+
groups = [g for g in groups if not has_dos(g)]
|
|
298
|
+
groups.append(_json_entry(spec, command))
|
|
299
|
+
hooks[event] = groups
|
|
300
|
+
wired.append(event)
|
|
301
|
+
|
|
302
|
+
return settings, wired, already
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
# Codex TOML merge. Codex's config.toml is hand-edited and comment-rich, and the
|
|
307
|
+
# stdlib has no comment-preserving TOML writer (`tomllib` is read-only; `tomlkit`
|
|
308
|
+
# would break the PyYAML-only kernel dependency floor). So we APPEND a fenced block
|
|
309
|
+
# of `[[hooks.EVENT]]` tables rather than re-serialize the file — idempotent on the
|
|
310
|
+
# opening fence marker. PURE: text in, text out.
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
def _toml_block(spec: HostHookSpec) -> str:
|
|
313
|
+
"""The fenced `[[hooks.EVENT]]` block for a TOML host, as TOML text."""
|
|
314
|
+
lines = [TOML_FENCE_OPEN]
|
|
315
|
+
for event, command in spec.events_and_commands():
|
|
316
|
+
# TOML basic strings need backslashes/quotes escaped; the DOS commands use
|
|
317
|
+
# neither, but escape defensively so a future command stays valid TOML.
|
|
318
|
+
esc = command.replace("\\", "\\\\").replace('"', '\\"')
|
|
319
|
+
lines.append(f"[[hooks.{event}]]")
|
|
320
|
+
lines.append("[[hooks.%s.hooks]]" % event)
|
|
321
|
+
lines.append('type = "command"')
|
|
322
|
+
lines.append(f'command = "{esc}"')
|
|
323
|
+
lines.append("") # blank line between tables for readability.
|
|
324
|
+
lines.append(TOML_FENCE_CLOSE)
|
|
325
|
+
return "\n".join(lines)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def merge_toml(
|
|
329
|
+
existing_text: str, spec: HostHookSpec, *, force: bool = False
|
|
330
|
+
) -> tuple[str, list[str], list[str]]:
|
|
331
|
+
"""Append the DOS hooks to a TOML hook-config (Codex). PURE: text in, text out.
|
|
332
|
+
|
|
333
|
+
Returns `(text, wired, already)`. Idempotent on the opening fence marker: if a
|
|
334
|
+
DOS block is already present, the file is returned unchanged and every event is
|
|
335
|
+
reported `already` (unless `force`, which strips the old fenced block and
|
|
336
|
+
re-appends a fresh one — the repair path). The user's existing TOML (keys,
|
|
337
|
+
comments, other `[[hooks.*]]` tables) is never re-serialized, only appended to.
|
|
338
|
+
"""
|
|
339
|
+
text = existing_text if isinstance(existing_text, str) else ""
|
|
340
|
+
events = [ev for ev, _ in spec.events_and_commands()]
|
|
341
|
+
|
|
342
|
+
if TOML_FENCE_OPEN in text:
|
|
343
|
+
if not force:
|
|
344
|
+
return text, [], events
|
|
345
|
+
# Repair: excise the old fenced block (open..close inclusive), then re-append.
|
|
346
|
+
start = text.index(TOML_FENCE_OPEN)
|
|
347
|
+
close_at = text.find(TOML_FENCE_CLOSE, start)
|
|
348
|
+
if close_at != -1:
|
|
349
|
+
end = close_at + len(TOML_FENCE_CLOSE)
|
|
350
|
+
text = text[:start].rstrip() + text[end:]
|
|
351
|
+
# (A truncated block with no close marker: leave it; the append below adds a
|
|
352
|
+
# well-formed one and the operator can clean the stray opener.)
|
|
353
|
+
|
|
354
|
+
block = _toml_block(spec)
|
|
355
|
+
sep = "" if (text == "" or text.endswith("\n\n")) else ("\n" if text.endswith("\n") else "\n\n")
|
|
356
|
+
new_text = (text + sep + block + "\n") if text else (block + "\n")
|
|
357
|
+
return new_text, events, []
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# Read-only DETECTION — the inverse of the merges, for `dos doctor`. Given a host's
|
|
362
|
+
# already-parsed config (a dict for JSON, the raw text for TOML), report which of
|
|
363
|
+
# this host's DOS events are wired. PURE: no I/O (the file read is the caller's), so
|
|
364
|
+
# doctor stays read-only. Used to answer "did my `dos init --hooks` take effect?".
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
def wired_events_json(existing: dict, spec: HostHookSpec) -> list[str]:
|
|
367
|
+
"""Which of `spec`'s events already run a `dos hook …` command in a JSON config."""
|
|
368
|
+
if not isinstance(existing, dict):
|
|
369
|
+
return []
|
|
370
|
+
hooks = existing.get("hooks")
|
|
371
|
+
if not isinstance(hooks, dict):
|
|
372
|
+
return []
|
|
373
|
+
has_dos = _group_has_dos_command if spec.json_group_wraps else _entry_is_dos_command
|
|
374
|
+
found: list[str] = []
|
|
375
|
+
for event in [ev for ev, _ in spec.events_and_commands()]:
|
|
376
|
+
groups = hooks.get(event)
|
|
377
|
+
if isinstance(groups, list) and any(has_dos(g) for g in groups):
|
|
378
|
+
found.append(event)
|
|
379
|
+
return found
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def wired_events_toml(existing_text: str, spec: HostHookSpec) -> list[str]:
|
|
383
|
+
"""Which of `spec`'s events are wired in a TOML config (the DOS fence is present)."""
|
|
384
|
+
if not isinstance(existing_text, str) or TOML_FENCE_OPEN not in existing_text:
|
|
385
|
+
return []
|
|
386
|
+
# The DOS block wires ALL of the host's events at once (it is written as a unit),
|
|
387
|
+
# so the presence of the fence means every event is wired.
|
|
388
|
+
return [ev for ev, _ in spec.events_and_commands()]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
# Driver-spec discovery (boundary I/O — at resolve time, never inside a merge). The
|
|
393
|
+
# `dos.hook_installs` entry-point group, the same mechanism `hook_dialect` uses for
|
|
394
|
+
# `dos.hook_dialects`. Kept defensive: a broken plugin never breaks the default.
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
def _iter_entry_points():
|
|
397
|
+
try:
|
|
398
|
+
from importlib.metadata import entry_points
|
|
399
|
+
except Exception: # pragma: no cover - very old Python
|
|
400
|
+
return []
|
|
401
|
+
try:
|
|
402
|
+
eps = entry_points()
|
|
403
|
+
if hasattr(eps, "select"):
|
|
404
|
+
return list(eps.select(group="dos.hook_installs"))
|
|
405
|
+
return list(eps.get("dos.hook_installs", [])) # type: ignore[attr-defined]
|
|
406
|
+
except Exception:
|
|
407
|
+
return []
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _plugin_spec_names() -> list[str]:
|
|
411
|
+
return [ep.name for ep in _iter_entry_points()]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _coerce_spec(obj: object) -> Optional[HostHookSpec]:
|
|
415
|
+
"""A registered target may be a HostHookSpec, or a zero-arg factory returning one."""
|
|
416
|
+
if isinstance(obj, HostHookSpec):
|
|
417
|
+
return obj
|
|
418
|
+
if callable(obj):
|
|
419
|
+
try:
|
|
420
|
+
built = obj()
|
|
421
|
+
except Exception:
|
|
422
|
+
return None
|
|
423
|
+
if isinstance(built, HostHookSpec):
|
|
424
|
+
return built
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _load_plugin_spec(name: str) -> Optional[HostHookSpec]:
|
|
429
|
+
for ep in _iter_entry_points():
|
|
430
|
+
if ep.name != name:
|
|
431
|
+
continue
|
|
432
|
+
try:
|
|
433
|
+
obj = ep.load()
|
|
434
|
+
except Exception:
|
|
435
|
+
return None
|
|
436
|
+
return _coerce_spec(obj)
|
|
437
|
+
return None
|