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_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