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/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.
|