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/posttool_sensor.py ADDED
@@ -0,0 +1,528 @@
1
+ """posttool-sensor — the boundary I/O for the tool_stream axis (docs/173 §4, §5).
2
+
3
+ > **`tool_stream.classify_stream` is a PURE verdict over a frozen `ToolStream`.
4
+ > SOMETHING has to turn a live PostToolUse hook event into a `StreamStep`, persist
5
+ > the accumulating stream across the many short-lived hook invocations of one
6
+ > session, and turn a REPEATING/STALLED verdict into the exact bytes real Claude
7
+ > Code honors. That is this module — the tool_stream axis's `resume_evidence`:
8
+ > boundary I/O (the hook event, the session-scoped `.dos/streams/<sid>.jsonl`)
9
+ > feeding the pure core, never inside the verdict.**
10
+
11
+ `liveness` reads git/journal at the CLI boundary and hands the already-gathered
12
+ delta to the pure `classify`. `resume_evidence` reads git ancestry at the boundary
13
+ and hands the frozen `AncestryFacts` to the pure `resume_plan`. This module is the
14
+ SAME shape one rung over, for `tool_stream`: the PostToolUse hook fires once per
15
+ tool call, so the "stream" exists only as an accumulating fossil — we APPEND one
16
+ `StreamStep` per fire (the impure part, the WAL idiom borrowed from `intent_ledger`)
17
+ and REPLAY the whole session's steps back into a `ToolStream` the pure
18
+ `classify_stream` folds. The kernel hashes nothing live inside the verdict; the
19
+ hashing of the event's args + result bytes happens HERE, at the boundary, exactly
20
+ as `tool_stream`'s docstring requires ("the CALLER computes the `args_digest` /
21
+ `result_digest` at the boundary").
22
+
23
+ Two pure halves + one impure (boundary) half:
24
+
25
+ * **`step_from_event(event)`** — PURE. Turn one PostToolUse hook event into a
26
+ `StreamStep`: the agent-authored `tool_name`/`tool_input` → `args_digest`, and
27
+ the ENV-authored result bytes → `result_digest`. Hashes pre-supplied event
28
+ fields (no disk, no clock, no network) — the same "I/O is the caller's, the
29
+ transform is pure" line `tool_stream` draws around `classify_stream`.
30
+ * **`warn_payload(verdict)`** — PURE. Turn a REPEATING/STALLED `StreamVerdict`
31
+ into the exact Claude-Code PostToolUse `additionalContext` dialect (the
32
+ `hookSpecificOutput` envelope), or None for ADVANCING (emit nothing).
33
+ * **the accumulator** (`append_step`/`read_stream`, the "boundary I/O" section
34
+ below) — the one impure part: an append-only, `fsync`'d, schema-tagged,
35
+ torn-tail-tolerant session log, byte-mirroring `intent_ledger`'s ARIES
36
+ discipline so the stream survives across the many separate hook processes of
37
+ one session.
38
+
39
+ Why it is byte-clean (the docs/138 invariant, inherited from `tool_stream`)
40
+ ===========================================================================
41
+
42
+ Walk the provenance of the `StreamStep` this adapter mints (the §5a argument
43
+ `tool_stream` makes, restated for the live hook):
44
+
45
+ * `tool_name` / `args_digest` — the **agent** authored these (it chose the call
46
+ and its arguments — `tool_input` is the agent-authored arg packet).
47
+ * `result_digest` — the **env** authored these. In a PostToolUse event
48
+ the result key (`tool_response`, or `tool_output` on some CC builds) is the
49
+ bytes the TOOL/MCP server returned, not bytes the agent wrote. The judged agent
50
+ did **not** author the *identity* of its own repeated tool results.
51
+
52
+ So the only question `tool_stream` asks of this stream — "is this env-authored
53
+ `result_digest` byte-identical to one the env already returned, N times in a row?"
54
+ — is a pure byte question about **env-authored** bytes, unforgeable in the agent's
55
+ favor. This sensor never asks the dangerous satisfaction predicate ("is the agent
56
+ making progress / has it done the right thing?") that the §5a trap forbids.
57
+
58
+ Why it is ADVISORY, and the honest hole (named, not buried)
59
+ ===========================================================
60
+
61
+ PostToolUse fires AFTER the tool already ran, so a PostToolUse hook **cannot
62
+ block** — it is structurally incapable of cutting the turn, which is exactly the
63
+ docs/99 advisory-only doctrine made unavoidable by the host contract. The only
64
+ lever it has is `additionalContext`: it can ADD a re-surfaced fact to the model's
65
+ next turn, never remove one. So this sensor RE-SURFACES the env-authored value the
66
+ agent already holds (and points at waiting for a completion signal), and it does so
67
+ even on STALLED — never a command to stop.
68
+
69
+ That is the right shape because of the honest hole `tool_stream` names and this
70
+ sensor inherits: **eventual-consistency polling is a legitimate reason to re-read
71
+ with the same result.** A task correctly waiting for an async write to land
72
+ produces identical reads until it lands — a true REPEATING that is *not* a stall.
73
+ Re-surfacing the unchanged value is harmless if the agent was right to wait (it
74
+ ignores a value it does not yet need) and helpful if it was stuck (it gets the
75
+ value it kept failing to use). Quoting `tool_stream`'s own reasoning: "the
76
+ intervention a consumer attaches to REPEATING must be a WARN that re-surfaces the
77
+ value, never a cut." This module is that consumer, on the live hook seam.
78
+
79
+ The catch-of-record (the in-flight twin of `dos_solves_output_poll.py`)
80
+ =======================================================================
81
+
82
+ dos session ``2cd77e93`` polled an unchanged background-task ``.output`` file 5×
83
+ (identical 126-byte result ``deedb29c`` each read), STALLED at read 5;
84
+ ``benchmark/toolathlon/dos_solves_output_poll.py`` proves the OFFLINE replay fired.
85
+ This module is the IN-FLIGHT version of that proof: the same five identical-result
86
+ events, fed one at a time through this sensor's accumulator, fire REPEATING by the
87
+ 3rd and STALLED by the 5th — re-surfacing "the .output is unchanged; wait for the
88
+ completion notification" on the SAME budget, the moment the loop is established.
89
+
90
+ ⚓ Kernel discipline (the litmus): this is a PURE verdict-adapter — it imports only
91
+ sibling kernel modules (`tool_stream`, `config`, `durable_schema`), names no host /
92
+ driver, resolves every path via `SubstrateConfig.paths` (never `__file__`), and
93
+ carries no policy of its own (the thresholds live in `StreamPolicy`, the CLI hands
94
+ in `cfg.stream_policy`).
95
+ """
96
+
97
+ from __future__ import annotations
98
+
99
+ import datetime as dt
100
+ import hashlib
101
+ import json
102
+ import os
103
+ import sys
104
+ from pathlib import Path
105
+ from typing import Optional
106
+
107
+ try:
108
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
109
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
110
+ except Exception:
111
+ pass
112
+
113
+ from dos import config as _config
114
+ from dos import durable_schema as _schema
115
+ from dos.tool_stream import (
116
+ StreamPolicy,
117
+ StreamState,
118
+ StreamStep,
119
+ StreamVerdict,
120
+ ToolStream,
121
+ )
122
+
123
+ # The durable-schema family + version every stream record carries (§6). Bumped
124
+ # ONLY on a NON-additive shape change (a new optional field is additive and does
125
+ # NOT bump it — the `durable_schema` contract). This kernel UNDERSTANDS up to
126
+ # `TOOL_STREAM_SCHEMA`; a record tagged higher is REFUSED at read (`read_stream`'s
127
+ # schema gate), never best-effort-parsed into a forged repeat.
128
+ SCHEMA_FAMILY = "tool-stream"
129
+ TOOL_STREAM_SCHEMA = 1
130
+
131
+ # The directory the per-session stream logs live under, beneath `.dos/`. A sibling
132
+ # of `intent_ledger`'s `.dos/runs/<run_id>/` — keyed by the host-authored
133
+ # `session_id` rather than a kernel run-id, because the PostToolUse event carries a
134
+ # `session_id`, not a DOS run-id (the join to a run-id is a later phase; the stream
135
+ # only needs a stable per-session key to accumulate under).
136
+ STREAMS_DIRNAME = "streams"
137
+
138
+ # The PostToolUse result key. Current Claude Code docs name it `tool_response`; some
139
+ # versions/builds emit `tool_output`. We READ BOTH defensively (the dual-read is
140
+ # mandatory robustness, not optional) — the same fail-safe direction as reading a
141
+ # missing key as "no result" rather than crashing. (docs/173 §4.)
142
+ _RESULT_KEYS = ("tool_response", "tool_output")
143
+
144
+
145
+ def _now_iso() -> str:
146
+ """Second-resolution UTC stamp for a stream record (the `intent_ledger` idiom)."""
147
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # The PURE adapter — a hook event in, a StreamStep out (no I/O).
152
+ # ---------------------------------------------------------------------------
153
+ def _digest(b: bytes) -> str:
154
+ """The truncated SHA every digest uses. PURE.
155
+
156
+ Truncated to 16 hex chars to MATCH `dos_solves_output_poll.py`'s `_digest`
157
+ (`hexdigest()[:16]`) so the live sensor and the offline proof artifact compute
158
+ byte-identical digests over the same bytes — the in-flight twin really is the
159
+ same signal, not a look-alike.
160
+ """
161
+ return hashlib.sha256(b).hexdigest()[:16]
162
+
163
+
164
+ def _canonical_bytes(value) -> bytes:
165
+ """Canonical UTF-8 bytes of a JSON-able value (sorted keys). PURE.
166
+
167
+ A string is hashed as its own bytes (the env returned text — hash the text, the
168
+ `dos_solves_output_poll` posture for a `.output` result block). Any other JSON
169
+ value (a dict/list/number a structured-result tool returned) is hashed as its
170
+ canonical `json.dumps` (sorted keys, no incidental whitespace), so two
171
+ byte-equal results digest equally regardless of key order. `default=str` keeps a
172
+ non-JSON-able scalar (a stray datetime) from raising — the fail-safe break.
173
+ """
174
+ if isinstance(value, str):
175
+ return value.encode("utf-8", "replace")
176
+ return json.dumps(value, sort_keys=True, default=str, ensure_ascii=False).encode(
177
+ "utf-8", "replace"
178
+ )
179
+
180
+
181
+ def _result_from_event(event: dict):
182
+ """The result object the env returned, read from BOTH candidate keys. PURE.
183
+
184
+ Reads `tool_response` first (the current-docs key), falling back to `tool_output`
185
+ (the older/alternate build key) — the mandatory dual-read (docs/173 §4). Returns
186
+ a 2-tuple `(present, value)`: `present=False` when NEITHER key is in the event
187
+ (a call that errored / returned nothing), which the caller maps to
188
+ `result_digest=None` — the fail-safe break (no result is never 'the same
189
+ result'). A key present with value `None` is treated as ABSENT too (an explicit
190
+ null result is no result), the same safe direction.
191
+ """
192
+ for k in _RESULT_KEYS:
193
+ if k in event:
194
+ v = event.get(k)
195
+ if v is not None:
196
+ return True, v
197
+ return False, None
198
+
199
+
200
+ def step_from_event(event: dict, policy: "StreamPolicy | None" = None) -> Optional[StreamStep]:
201
+ """Turn one PostToolUse hook event into a `StreamStep`. PURE — hashes event fields only.
202
+
203
+ The boundary-coordinate adapter: it reads the event's agent-authored
204
+ `tool_name`/`tool_input` and the env-authored result (dual-key
205
+ `tool_response`/`tool_output`), and computes the two digests `tool_stream` keys
206
+ on. Returns None when there is no `tool_name` — nothing to record (the event was
207
+ not a tool call, or was malformed); the caller emits nothing.
208
+
209
+ * `args_digest` — sha256 of the NORMALIZED `tool_input` (sorted keys,
210
+ canonical JSON — the `dos_solves_output_poll` normalization
211
+ and the `StreamStep` docstring's "sorted keys, canonical
212
+ scalar repr"). AGENT-authored: the agent chose the call's
213
+ arguments. Prefixed with the tool name so two different
214
+ tools with byte-equal args never collide on a digest.
215
+ * `result_digest` — sha256 of the ENV-returned result bytes
216
+ (`_canonical_bytes` of the result object). ENV-authored —
217
+ the load-bearing field. None when the event carried no
218
+ result (a call that errored / returned nothing) — None
219
+ never matches another step, so it BREAKS a run rather than
220
+ extending it (the fail-safe; the `StreamStep` contract).
221
+
222
+ `policy` is accepted for signature-symmetry with the rest of the axis (a future
223
+ boundary normalization a policy might tune); the v1 adapter does not branch on
224
+ it. PURE: no disk, no clock, no network — only `hashlib`/`json` over the
225
+ already-supplied event, the `tool_stream` "the kernel hashes nothing live inside
226
+ the verdict; the boundary computes the digests" line.
227
+ """
228
+ if not isinstance(event, dict):
229
+ return None
230
+ tool_name = event.get("tool_name")
231
+ if not (isinstance(tool_name, str) and tool_name):
232
+ return None # not a tool call (or malformed) — nothing to record
233
+
234
+ tool_input = event.get("tool_input")
235
+ if tool_input is None:
236
+ tool_input = {}
237
+ # The args digest is over the tool name + the normalized input, so two
238
+ # different tools that happen to share an arg packet are never one repeat run.
239
+ args_blob = _canonical_bytes({"tool": str(tool_name), "input": tool_input})
240
+ args_digest = _digest(args_blob)
241
+
242
+ present, result = _result_from_event(event)
243
+ result_digest = _digest(_canonical_bytes(result)) if present else None
244
+
245
+ return StreamStep(
246
+ tool_name=str(tool_name),
247
+ args_digest=args_digest,
248
+ result_digest=result_digest,
249
+ )
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # The PURE warn renderer — a StreamVerdict in, the exact CC dialect out (no I/O).
254
+ # ---------------------------------------------------------------------------
255
+ def warn_payload(verdict: StreamVerdict) -> Optional[dict]:
256
+ """Render a REPEATING/STALLED `StreamVerdict` as the EXACT Claude-Code WARN dialect. PURE.
257
+
258
+ Returns the non-blocking PostToolUse `additionalContext` envelope — and NOTHING
259
+ ELSE — or None for ADVANCING (emit nothing). The shape is the ONE dialect real
260
+ Claude Code honors (verified against code.claude.com/docs); the field names are
261
+ case-sensitive and exact:
262
+
263
+ {"hookSpecificOutput": {"hookEventName": "PostToolUse",
264
+ "additionalContext": "<text>"}}
265
+
266
+ This is the load-bearing correctness fact: the sibling `dos hook stop` is a
267
+ SILENT NO-OP against real CC because it emits `{"ok": false}`, a dialect CC
268
+ ignores. This sensor MUST emit `hookSpecificOutput`/`PostToolUse`/
269
+ `additionalContext` exactly, or it is invisible the same way.
270
+
271
+ The `additionalContext` re-surfaces the ENV-AUTHORED fact (never an agent
272
+ judgment): it names the repeated tool, the `repeat_run` count, and that the env
273
+ returned identical bytes N times so no new information is entering the loop —
274
+ advising the agent to WAIT for a completion signal / use the value it already
275
+ holds. It NEVER tells the agent to stop (PostToolUse cannot block; and a
276
+ legitimate poll must not be cut — the docs/99 advisory line, the honest
277
+ eventual-consistency hole made structural).
278
+ """
279
+ if verdict.state not in (StreamState.REPEATING, StreamState.STALLED):
280
+ return None # ADVANCING (or anything else) → emit nothing
281
+ rs = verdict.repeated_step
282
+ tool = rs.tool_name if rs is not None else "the same tool"
283
+ digest = rs.result_digest if rs is not None else "(unknown)"
284
+ text = (
285
+ f"DOS tool_stream {verdict.state.value}: `{tool}` returned BYTE-IDENTICAL "
286
+ f"results {verdict.repeat_run} times in a row (env-authored digest "
287
+ f"{digest}) — no new information is entering the loop. The value you already "
288
+ f"received has not changed; do NOT re-issue the same call expecting a "
289
+ f"different answer. If you are polling a background task / an async write, "
290
+ f"WAIT for its completion signal instead of re-reading; otherwise USE the "
291
+ f"value you already hold and move on. ({verdict.reason})"
292
+ )
293
+ return {
294
+ "hookSpecificOutput": {
295
+ "hookEventName": "PostToolUse",
296
+ "additionalContext": text,
297
+ }
298
+ }
299
+
300
+
301
+ # ===========================================================================
302
+ # Boundary I/O — the ONE impure part: the session-scoped accumulator.
303
+ # Byte-mirrors `intent_ledger`'s ARIES discipline (fsync, O_APPEND, torn-tail
304
+ # tolerance, the §6 schema gate). A stream record is one append-only line; the
305
+ # whole session's lines REPLAY into a ToolStream the pure verdict folds.
306
+ # ===========================================================================
307
+ def _safe_session_name(session_id: str) -> Optional[str]:
308
+ """The sanitized filename stem for a host-authored `session_id`, or None to skip.
309
+
310
+ `session_id` is an agent/host-authored token (the PostToolUse event's
311
+ `session_id`) — distrusted as a filename. We strip any path separators and
312
+ drive/`..` components so it can never escape the streams dir (a path-traversal
313
+ surface, the `_resolve_driver_config` dotted-name reflex), keeping only the safe
314
+ characters of a normal session uuid. An empty/whitespace token (or one that
315
+ sanitizes to empty) returns None — no identity, no accumulator (the caller emits
316
+ nothing rather than writing to a junk path).
317
+ """
318
+ if not isinstance(session_id, str):
319
+ return None
320
+ # Keep only characters safe in a filename across OSes; drop separators + dots
321
+ # used for traversal. A session uuid is `[0-9a-f-]`, so this is loss-free for the
322
+ # real key and defensive for a hostile one.
323
+ safe = "".join(c for c in session_id if c.isalnum() or c in "-_")
324
+ return safe or None
325
+
326
+
327
+ def streams_dir_for(cfg: "_config.SubstrateConfig | None" = None) -> Path:
328
+ """The `.dos/streams/` directory under the active workspace. PURE path arithmetic.
329
+
330
+ Rides `cfg.paths.dot_dos` (the per-project `.dos/` home), the sibling of
331
+ `intent_ledger`'s `.dos/runs/`. Never creates the dir — `append_step` is the
332
+ only creator (the read-only-path discipline)."""
333
+ cfg = _config.ensure(cfg)
334
+ return cfg.paths.dot_dos / STREAMS_DIRNAME
335
+
336
+
337
+ def stream_path_for(
338
+ session_id: str, cfg: "_config.SubstrateConfig | None" = None
339
+ ) -> Optional[Path]:
340
+ """The `.dos/streams/<session_id>.jsonl` path for a session, or None if unusable.
341
+
342
+ Pure path arithmetic (the `intent_ledger.ledger_path_for` idiom): never creates
343
+ anything. Returns None when `session_id` sanitizes to empty (no safe filename) —
344
+ the caller treats that as "no accumulator," emitting nothing.
345
+ """
346
+ safe = _safe_session_name(session_id)
347
+ if safe is None:
348
+ return None
349
+ return streams_dir_for(cfg) / f"{safe}.jsonl"
350
+
351
+
352
+ def _step_entry(
353
+ step: StreamStep,
354
+ *,
355
+ run_id: str | None = None,
356
+ step_index: int | None = None,
357
+ verdict_state: str | None = None,
358
+ ) -> dict:
359
+ """The durable record for one `StreamStep` — schema-tagged, canonical. PURE.
360
+
361
+ Carries the §6 schema tag so a record written directly (not via `append_step`)
362
+ is self-declaring, the `intent_ledger.*_entry` posture. A `None` `result_digest`
363
+ is written as JSON `null` and reads back as None (the fail-safe break survives
364
+ the round-trip).
365
+
366
+ The three join-fields below are the docs/179 Phase-0 additions that turn a step
367
+ record into a labelable *firing*. They are ADDITIVE optional fields — present
368
+ only when known — so a record without them reads back identically to a v1 record
369
+ and the schema version does NOT bump (the `durable_schema` additive contract:
370
+ a new optional field is forward/backward compatible). They are written ONLY when
371
+ non-None, so the common (no-spine) record is byte-for-byte the old one and the
372
+ whole shipped `tool_stream` suite stays green:
373
+
374
+ * `run_id` — the DOS correlation-spine id for this step's run, the join
375
+ key the firing-label fold (docs/179) needs to reach the
376
+ run's git-minted ground truth (`trace.build_trace`). The
377
+ PostToolUse event carries only a host `session_id`; the
378
+ caller resolves the run_id from the active spine (env / a
379
+ run-dir) when present, else leaves it absent — and an
380
+ absent run_id is an honest `BROKEN_LINK`, never a guess.
381
+ * `step_index` — this step's 0-based ordinal WITHIN the session stream (the
382
+ count of prior records). Makes "the detector fired at
383
+ step N" a durable fact joinable to the stream position,
384
+ rather than something re-derived on every replay.
385
+ * `verdict_state` — the `StreamState` value (REPEATING/STALLED) the detector
386
+ emitted AT this step, stamped only on a record that
387
+ actually fired. This is what makes the record a *firing*:
388
+ without it the fold would have to re-run the verdict over a
389
+ replay and guess which step it fired on. ADVANCING is never
390
+ stamped (no firing → no field), so the presence of
391
+ `verdict_state` IS the firing.
392
+ """
393
+ e = {
394
+ **_schema.tag(SCHEMA_FAMILY, TOOL_STREAM_SCHEMA),
395
+ "op": "STEP",
396
+ "tool_name": step.tool_name,
397
+ "args_digest": step.args_digest,
398
+ "result_digest": step.result_digest,
399
+ }
400
+ if run_id:
401
+ e["run_id"] = run_id
402
+ if step_index is not None:
403
+ e["step_index"] = step_index
404
+ if verdict_state:
405
+ e["verdict_state"] = verdict_state
406
+ return e
407
+
408
+
409
+ def append_step(
410
+ session_id: str,
411
+ step: StreamStep,
412
+ cfg: "_config.SubstrateConfig | None" = None,
413
+ *,
414
+ path: Path | None = None,
415
+ run_id: str | None = None,
416
+ step_index: int | None = None,
417
+ verdict_state: str | None = None,
418
+ ) -> None:
419
+ """Append ONE `StreamStep` to the session's stream log and `fsync` it.
420
+
421
+ Copies `intent_ledger.append`'s durability idiom EXACTLY: stamp the record (the
422
+ §6 schema tag + a `ts`), write one canonical-JSON line + newline through
423
+ `os.open(O_WRONLY|O_APPEND|O_CREAT)` + `os.write` + `os.fsync` + `os.close`, so
424
+ the record is durable before this returns and the append is atomic w.r.t. any
425
+ other appender at the OS level. `mkdir(parents=True)` the streams dir lazily (the
426
+ only creator). `path` overrides the resolved location (tests).
427
+
428
+ `run_id`/`step_index`/`verdict_state` are the docs/179 additive firing-join
429
+ fields (see `_step_entry`): pass them to make this step a labelable firing. All
430
+ optional — omitting them writes the byte-identical v1 record.
431
+
432
+ Raises on an unusable `session_id` (no `path` and the session sanitizes to
433
+ empty) — the CLI wraps this whole call in a fail-safe try/except (advisory: never
434
+ block a real workflow on the sensor's own write failure), so a raise here degrades
435
+ to "emit nothing," never a crashed turn.
436
+ """
437
+ p = path or stream_path_for(session_id, cfg)
438
+ if p is None:
439
+ raise ValueError("append_step needs a usable session_id or an explicit path")
440
+ e = _step_entry(
441
+ step, run_id=run_id, step_index=step_index, verdict_state=verdict_state
442
+ )
443
+ e.setdefault("ts", _now_iso())
444
+ line = json.dumps(e, sort_keys=True, default=str, ensure_ascii=False) + "\n"
445
+ p.parent.mkdir(parents=True, exist_ok=True)
446
+ fd = os.open(str(p), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
447
+ try:
448
+ os.write(fd, line.encode("utf-8"))
449
+ os.fsync(fd)
450
+ finally:
451
+ os.close(fd)
452
+
453
+
454
+ def read_stream(
455
+ session_id: str,
456
+ cfg: "_config.SubstrateConfig | None" = None,
457
+ *,
458
+ path: Path | None = None,
459
+ understands: int = TOOL_STREAM_SCHEMA,
460
+ ) -> ToolStream:
461
+ """Replay the session's stream log into a `ToolStream`. The accumulator read-side.
462
+
463
+ Two distrust postures layered, byte-mirroring `intent_ledger.read_all`:
464
+
465
+ * **Torn-tail tolerance** — an unparseable TRAILING line (a crash mid-append)
466
+ is skipped: a half-written record is "didn't happen." A non-trailing
467
+ unparseable line is dropped too (a stream is a best-effort fossil, not a
468
+ ledger whose every gap must be flagged) — the safe direction is to under-count
469
+ a repeat, never to over-count one.
470
+ * **Schema gate** (§6) — a record whose `schema` tag is a NON-additively-newer
471
+ version than `understands` is NOT parsed into a `StreamStep`; it is SKIPPED
472
+ (treated as un-foldable), so a record this kernel is too old to read can never
473
+ fabricate a repeat. An UNTAGGED (legacy) record is read permissively as v1
474
+ (the `durable_schema.UNTAGGED` tolerant side); a WRONG_FAMILY record (a
475
+ foreign line) is skipped.
476
+
477
+ A record missing/`null` `result_digest` reads back as `result_digest=None` (the
478
+ fail-safe break survives). Returns an EMPTY `ToolStream` when the file is absent —
479
+ the too-young-to-judge floor `tool_stream` reads as ADVANCING. `understands` is
480
+ injectable so a test can simulate an OLD reader meeting a NEW record.
481
+ """
482
+ p = path or stream_path_for(session_id, cfg)
483
+ if p is None or not p.exists():
484
+ return ToolStream(())
485
+ try:
486
+ raw = p.read_text(encoding="utf-8", errors="replace")
487
+ except OSError:
488
+ return ToolStream(())
489
+ lines = raw.splitlines()
490
+ steps: list[StreamStep] = []
491
+ for i, line in enumerate(lines):
492
+ s = line.strip()
493
+ if not s:
494
+ continue
495
+ try:
496
+ obj = json.loads(s)
497
+ except json.JSONDecodeError:
498
+ # Torn final line → "didn't happen"; a mid-file corrupt line → skip (a
499
+ # stream under-counts a repeat rather than fabricating one).
500
+ continue
501
+ if not isinstance(obj, dict):
502
+ continue
503
+ # The §6 schema gate. READABLE/UNTAGGED proceed; UNREADABLE_NEWER and
504
+ # WRONG_FAMILY are skipped (a too-new/foreign record never forges a repeat).
505
+ v = _schema.classify(obj, family=SCHEMA_FAMILY, understands=understands)
506
+ if v.readability not in (_schema.Readability.READABLE, _schema.Readability.UNTAGGED):
507
+ continue
508
+ tool_name = obj.get("tool_name")
509
+ args_digest = obj.get("args_digest")
510
+ if not (isinstance(tool_name, str) and isinstance(args_digest, str)):
511
+ continue # a record with no identity is not a comparable step
512
+ rd = obj.get("result_digest")
513
+ result_digest = rd if isinstance(rd, str) else None
514
+ steps.append(
515
+ StreamStep(
516
+ tool_name=tool_name,
517
+ args_digest=args_digest,
518
+ result_digest=result_digest,
519
+ )
520
+ )
521
+ return ToolStream(tuple(steps))
522
+
523
+
524
+ # FOLLOW-UP (not v1): a keep-last-N / size guard on the per-session stream log. The
525
+ # `.dos` reaper family already bounds growth elsewhere; a long session's stream is a
526
+ # small append-only file, and the verdict only needs the TRAILING run, so an
527
+ # unbounded read is acceptable for v1. A future trim (keep the last `stall_n + k`
528
+ # records) belongs with the reaper, not here.