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/marker_gate.py ADDED
@@ -0,0 +1,254 @@
1
+ """marker-gate — the PURE arming decision for the wait-marker budget (docs/274).
2
+
3
+ > **`marker_sensor` is the boundary I/O (the per-session no-op tally) and
4
+ > `noop_streak.classify` / `loop_decide.wait_marker_budget` is the pure
5
+ > count-vs-cap BUDGET verdict. This module is the third piece they were missing:
6
+ > the pure ARMING decision — "should this `Stop` event be subject to the budget at
7
+ > ALL?" — extracted out of `cli.cmd_hook_marker` so the docs/274 fix is one named,
8
+ > unit-tested function instead of two inline `if` blocks, and so its inputs are
9
+ > declarable per-workspace in `dos.toml [marker]`.**
10
+
11
+ The problem this closes (docs/274, the inversion it fixed)
12
+ ==========================================================
13
+
14
+ A Claude Code `Stop` hook fires when Claude finishes **any** turn — interactive
15
+ included — not only on a keep-alive *poll* turn, and a `{"decision":"block"}`
16
+ FORCES the agent to keep working. The wait-marker budget's polarity assumes a
17
+ `Stop` means "the loop chose not to stop, i.e. it is about to poll again"; on a
18
+ bare/global Stop binding that premise is FALSE, so an unscoped budget blocks every
19
+ ordinary turn and MANUFACTURES the very keep-alive cache-replay waste it exists to
20
+ cap (docs/274: 44 sessions, 35 walled at the 4/4 cap, 0 actual polls). The fix is
21
+ to ARM the budget only when there is positive evidence this `Stop` is a poll inside
22
+ a loop, and to honor Claude Code's own infinite-loop backstop (`stop_hook_active`).
23
+
24
+ This module is the *policy* half of that fix: the two guards, made a pure function
25
+ of an injected environment + a declared `MarkerPolicy`. The CLI (`cmd_hook_marker`)
26
+ gathers the impure inputs (the Stop event JSON, the process `os.environ`, the
27
+ `--loop` flag) and calls `decide()`; the budget arithmetic stays in `noop_streak`.
28
+
29
+ The arming signal IS the missing evidence (the load-bearing idea)
30
+ ================================================================
31
+
32
+ The budget's flaw was that its trigger ("this `Stop` is a poll") was an *assumption
33
+ about the moment*, not *evidence read from it* — which is exactly why it was the one
34
+ DOS hook that inverted (`dos hook stop` blocks only on a claim-vs-git contradiction;
35
+ `dos hook pretool` denies only on a real lease collision). The arming signal
36
+ (`--loop`, or a loop-scoping env var the dispatch loop sets) is the evidence the bare
37
+ event lacks — supplied from OUTSIDE the event, it proves the assumption holds. So
38
+ `decide()` is the discipline "an intervention is a safe default only if its trigger is
39
+ evidence" turned into code: no arming evidence → not armed → allow the stop.
40
+
41
+ Why the env-var NAMES are config, not hardcoded
42
+ ===============================================
43
+
44
+ A dispatch loop signals "I am a loop" by exporting a sentinel env var. The two
45
+ built-in defaults (`DOS_LOOP`, the correlation-spine `CID_RUN_ID` the marker record
46
+ already rides) cover the in-tree `/loop` and the reference userland app — but a
47
+ different host runs a different loop with a different sentinel. So the arming env-var
48
+ names are a declared `MarkerPolicy.arm_on_env` tuple, the same "closed-set-as-data"
49
+ pattern as `reasons`/`stamp`/`noop_streak`'s `max_streak`: mechanism (the arming
50
+ decision) is the kernel; policy (which signals arm it, what the cap is, whether to
51
+ honor `stop_hook_active`) is config a workspace declares in `dos.toml [marker]`.
52
+
53
+ Kernel discipline (the litmus)
54
+ ==============================
55
+
56
+ A PURE policy leaf — imports only stdlib (+ the declarative-config `tomllib` at
57
+ load time). Names no host and no vendor (the `stop_hook_active` field is a
58
+ DIALECT-NEUTRAL concept the Stop event carries; this module never branches on which
59
+ runtime is acting). `decide()` makes NO I/O at all — the caller injects the
60
+ environment as a plain mapping, so the arming truth table is replay-testable away
61
+ from `os.environ`. Passes `test_vendor_agnostic_kernel.py`.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ from dataclasses import dataclass
67
+ from pathlib import Path
68
+ from typing import Mapping
69
+
70
+
71
+ # The two built-in loop-sentinel env-var names (docs/274). `DOS_LOOP` is the generic
72
+ # opt-in a host exports on a keep-alive loop; `CID_RUN_ID` is the correlation-spine
73
+ # run-id the dispatch loop already sets (and the marker record already stamps), so a
74
+ # loop driven through the spine arms the budget with no extra wiring. A workspace
75
+ # REPLACES/extends this via `dos.toml [marker] arm_on_env` (a host with its own loop
76
+ # sentinel names it there).
77
+ DEFAULT_ARM_ON_ENV: tuple[str, ...] = ("DOS_LOOP", "CID_RUN_ID")
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class MarkerPolicy:
82
+ """The declared knobs for the wait-marker budget — policy, not mechanism.
83
+
84
+ The "mechanism is kernel, policy is config" split (the `noop_streak.NoOpStreakPolicy`
85
+ / `tool_stream.StreamPolicy` posture). A workspace declares its own in
86
+ `dos.toml [marker]`; the generic default is interactive-safe (armed only by an
87
+ explicit loop signal, never on an ordinary turn).
88
+
89
+ max_streak — the **no-op-turn budget** handed to `noop_streak.classify`: the most
90
+ consecutive keep-alive/poll turns a loop may take before the next is
91
+ refused. Default 4 (`wait_marker_budget`'s cap, one below the
92
+ `keepalive_poll` telemetry flag at >=5, so the runtime refusal lands
93
+ one turn before the post-hoc alarm). Must be non-negative.
94
+
95
+ arm_on_env — the env-var NAMES whose presence (any one, non-empty) ARMS the budget.
96
+ The evidence that this `Stop` is a poll inside a loop. Default
97
+ `("DOS_LOOP", "CID_RUN_ID")`; a host names its own loop sentinel here.
98
+ Empty () means "no env arms it" — only the explicit `--loop` flag does.
99
+
100
+ respect_stop_hook_active — honor Claude Code's own infinite-loop backstop
101
+ (docs/274 Case C): when the Stop event carries `stop_hook_active:true`
102
+ (this stop is ALREADY being continued by a prior hook block), do NOT
103
+ re-block it. Default True. A host that deliberately wants to keep
104
+ escalating an already-continued stop sets this False (rarely correct —
105
+ it is how a budget becomes a forced march).
106
+ """
107
+
108
+ max_streak: int = 4
109
+ arm_on_env: tuple[str, ...] = DEFAULT_ARM_ON_ENV
110
+ respect_stop_hook_active: bool = True
111
+
112
+ def __post_init__(self) -> None:
113
+ if self.max_streak < 0:
114
+ raise ValueError("max_streak must be non-negative")
115
+ # Normalize arm_on_env to a tuple of non-empty strings (a list from TOML, a
116
+ # stray empty/blank name) so `decide`'s `env.get(name)` walk is well-defined.
117
+ names = tuple(
118
+ n.strip() for n in self.arm_on_env if isinstance(n, str) and n.strip()
119
+ )
120
+ # frozen dataclass — set through object.__setattr__ (the canonical idiom).
121
+ object.__setattr__(self, "arm_on_env", names)
122
+
123
+
124
+ DEFAULT_POLICY = MarkerPolicy()
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class ArmDecision:
129
+ """Whether the budget arms for this `Stop`, with the operator-facing why.
130
+
131
+ `armed` is the load-bearing bit: True → the caller proceeds to the budget verdict
132
+ (and may block the Stop); False → the caller emits nothing (allow the stop), the
133
+ fail-safe direction. `reason` is for `--debug` — it names WHICH guard decided, so an
134
+ operator can see "not armed: no loop signal" vs "not armed: stop_hook_active" vs
135
+ "armed: DOS_LOOP set" without reading the code.
136
+ """
137
+
138
+ armed: bool
139
+ reason: str
140
+
141
+
142
+ def decide(
143
+ *,
144
+ stop_hook_active: bool,
145
+ loop_flag: bool,
146
+ env: Mapping[str, str],
147
+ policy: MarkerPolicy = DEFAULT_POLICY,
148
+ ) -> ArmDecision:
149
+ """Decide whether the wait-marker budget arms for this `Stop`. PURE — no I/O.
150
+
151
+ The two docs/274 guards, in order, as a function of injected inputs:
152
+
153
+ 1. `respect_stop_hook_active and stop_hook_active` → NOT armed. Claude Code's own
154
+ infinite-loop backstop: this stop is already being continued by a prior hook
155
+ block, so escalating it with another block is how a budget becomes a forced
156
+ march. Allow the stop. (Checked FIRST so an already-continued stop is never
157
+ re-blocked even inside a loop.)
158
+
159
+ 2. else armed ⟺ `loop_flag OR any(env.get(name) for name in policy.arm_on_env)`.
160
+ The TRIGGER guard: a `Stop` hook fires on every finished turn, so the budget
161
+ arms only with positive evidence this one is a keep-alive poll inside a loop —
162
+ an explicit `--loop`, or a loop-sentinel env var the dispatch loop set. No
163
+ such evidence → an ordinary interactive turn → NOT armed → allow the stop.
164
+
165
+ `env` is injected (a `Mapping`, typically `os.environ`) so the truth table is
166
+ replay-testable without mutating the process environment. A name maps to "present"
167
+ when `env.get(name)` is truthy (a set, non-empty value) — an empty string does NOT
168
+ arm (a host that exports `DOS_LOOP=""` to UNSET it is honored).
169
+ """
170
+ if policy.respect_stop_hook_active and stop_hook_active:
171
+ return ArmDecision(
172
+ armed=False,
173
+ reason=(
174
+ "stop_hook_active — stop already hook-continued; do not re-block; "
175
+ "allow stop"
176
+ ),
177
+ )
178
+ if loop_flag:
179
+ return ArmDecision(armed=True, reason="armed by --loop")
180
+ for name in policy.arm_on_env:
181
+ if env.get(name):
182
+ return ArmDecision(armed=True, reason=f"armed by env {name}")
183
+ arm_names = ", ".join(policy.arm_on_env) if policy.arm_on_env else "(none)"
184
+ return ArmDecision(
185
+ armed=False,
186
+ reason=(
187
+ f"no loop signal (--loop / env {arm_names}) — ordinary turn, not a "
188
+ f"keep-alive poll; allow stop (wait-marker budget arms only inside a loop)"
189
+ ),
190
+ )
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # The declarative on-ramp — read a policy out of dos.toml [marker]
195
+ # (mirror noop_streak/tool_stream/stamp: policy_from_table + load_from_toml).
196
+ # ---------------------------------------------------------------------------
197
+ def policy_from_table(table: dict, *, base: MarkerPolicy = DEFAULT_POLICY) -> MarkerPolicy:
198
+ """Turn a parsed `[marker]` TOML table into a `MarkerPolicy`. PURE (no I/O).
199
+
200
+ `table` is `{max_streak?, arm_on_env?, respect_stop_hook_active?}` — the shape
201
+ `tomllib.load(...)["marker"]` yields. A missing key falls back to `base` (default the
202
+ generic), so a partial table tunes only what it names. A malformed value (a negative
203
+ `max_streak`, a non-list `arm_on_env`) raises at construction
204
+ (`MarkerPolicy.__post_init__`), so a bad declaration fails loudly at load (the
205
+ `noop_streak.policy_from_table` posture).
206
+ """
207
+ if not table:
208
+ return base
209
+ arm = table.get("arm_on_env", base.arm_on_env)
210
+ # A scalar string is accepted as a single name (the common one-sentinel case);
211
+ # a list is the general case. Anything else falls back to base (fail-soft on shape,
212
+ # since the value is advisory config, not a verdict input).
213
+ if isinstance(arm, str):
214
+ arm_tuple: tuple[str, ...] = (arm,)
215
+ elif isinstance(arm, (list, tuple)):
216
+ arm_tuple = tuple(arm)
217
+ else:
218
+ arm_tuple = base.arm_on_env
219
+ return MarkerPolicy(
220
+ max_streak=int(table.get("max_streak", base.max_streak)),
221
+ arm_on_env=arm_tuple,
222
+ respect_stop_hook_active=bool(
223
+ table.get("respect_stop_hook_active", base.respect_stop_hook_active)
224
+ ),
225
+ )
226
+
227
+
228
+ def load_from_toml(
229
+ path: "Path | str", *, base: MarkerPolicy = DEFAULT_POLICY
230
+ ) -> MarkerPolicy:
231
+ """Build a `MarkerPolicy` from a `dos.toml`'s `[marker]` table.
232
+
233
+ Returns `base` unchanged when the file is absent, has no `[marker]` table, or
234
+ `tomllib` is unavailable — the declarative path is purely additive, so a
235
+ missing/empty config degrades to the generic default, never an error (the
236
+ `noop_streak.load_from_toml` contract). A *present but malformed* table raises
237
+ (`MarkerPolicy.__post_init__`). Reads with `utf-8-sig` to strip a PowerShell-written
238
+ BOM (the `reasons`/`tool_stream`/`noop_streak` `load_from_toml` fix).
239
+ """
240
+ p = Path(path)
241
+ if not p.exists():
242
+ return base
243
+ try:
244
+ import tomllib # py3.11+
245
+ except ModuleNotFoundError: # pragma: no cover - py<3.11 fallback
246
+ try:
247
+ import tomli as tomllib # type: ignore
248
+ except ModuleNotFoundError:
249
+ return base
250
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
251
+ table = data.get("marker")
252
+ if not isinstance(table, dict) or not table:
253
+ return base
254
+ return policy_from_table(table, base=base)
dos/marker_sensor.py ADDED
@@ -0,0 +1,396 @@
1
+ """marker-sensor — the boundary I/O for the wait-marker budget (loop_decide §wait-marker).
2
+
3
+ > **`loop_decide.wait_marker_budget(markers_emitted, max_markers)` is a PURE
4
+ > verdict over an integer count. SOMETHING has to remember, across the many
5
+ > short-lived hook invocations of one session, HOW MANY keep-alive markers this
6
+ > loop has already emitted — the Stop event the host hands us carries no such
7
+ > count. That is this module: the wait-marker axis's `posttool_sensor` — boundary
8
+ > I/O (the per-session `.dos/markers/<sid>.jsonl` tally) feeding the pure core,
9
+ > never inside the verdict.**
10
+
11
+ The problem this closes (docs: [[project-dos-poll-loop-antipattern]], the
12
+ `wait_marker_budget` docstring's "session 4b4ff97c burned 252 markers / ~$7.80"):
13
+ a `/loop`-style dispatch loop holds its turn open by emitting `claude -p`
14
+ keep-alive markers (or by re-reading a `.output` file in a tight tick), and each
15
+ marker is a FULL assistant turn that replays the whole system+skill+context out of
16
+ prompt cache for zero forward work. The pure `wait_marker_budget` can refuse a
17
+ marker once a budget is reached — but only if it is told the running count, and a
18
+ count threaded through a CLI flag (`--emitted N`) is exactly the "prose the model
19
+ must remember" the budget docstring criticizes. So we make the count
20
+ GROUND-TRUTH DURABLE STATE the model cannot forget: one append-only record per
21
+ marker, under the session's `.dos/markers/<sid>.jsonl`, replayed back to a count.
22
+
23
+ Why this is the EXACT sibling of `posttool_sensor`
24
+ ==================================================
25
+
26
+ `posttool_sensor` is the in-flight boundary for `tool_stream`: append one
27
+ `StreamStep` per PostToolUse fire, replay the session's steps into a `ToolStream`
28
+ the pure `classify_stream` folds. This module is the SAME shape for the wait-marker
29
+ budget, but the fold is the simplest possible one — a COUNT:
30
+
31
+ * **the accumulator** (`record_marker` / `marker_count`) — the one impure part:
32
+ an append-only, `fsync`'d, schema-tagged, torn-tail-tolerant session tally,
33
+ byte-mirroring `intent_ledger`'s ARIES discipline (the same idiom
34
+ `posttool_sensor.append_step` / `read_stream` copy) so the count survives across
35
+ the many separate hook processes of one session. A record is one tagged
36
+ `{"op":"MARKER"}` line; the COUNT is the number of valid records replayed back.
37
+ * the verdict itself is `loop_decide.wait_marker_budget` — PURE, already shipped,
38
+ already green. This module never re-implements the allow/refuse arithmetic; it
39
+ only supplies the count and persists the increment.
40
+
41
+ The polarity, stated sharply (the load-bearing correctness fact)
42
+ ================================================================
43
+
44
+ This binds to a **Stop** hook, and its polarity is the INVERSE of `cmd_hook_stop`.
45
+ A keep-alive wait-marker is the loop CHOOSING NOT TO STOP — blocking its own Stop
46
+ to keep waiting. So:
47
+
48
+ * budget REMAINS (`wait_marker_budget(...).allow is True`) → the loop MAY emit
49
+ another marker → **block the Stop** (`{"decision":"block", "reason": …}`),
50
+ holding the turn open one more marker.
51
+ * budget EXHAUSTED (`.allow is False`) → stop polling →
52
+ **allow the Stop** (emit NOTHING — an empty Stop output is CC's "allow stop") →
53
+ the loop ends its turn and waits on the real Bash `<task-notification>`, which
54
+ fires on the child's true exit regardless ([[project-dos-poll-loop-antipattern]]).
55
+
56
+ `cmd_hook_stop` BLOCKS a *false done* (the agent claimed ship, git disagrees);
57
+ this BLOCKS a *premature give-up only while the marker budget is unspent*, then
58
+ gets out of the way. Two Stop hooks, opposite triggers — keep them apart.
59
+
60
+ Why it is ADVISORY and fail-safe
61
+ ================================
62
+
63
+ The kernel stays a PDP, not a PEP (docs/99): the block is a PROPOSAL the runtime
64
+ consumes. Every failure mode degrades to "emit nothing, exit 0" = let the agent
65
+ stop — a missing stdin, unparseable JSON, an unusable session_id, an accumulator
66
+ I/O error. The hook can refuse to keep a loop polling past its budget; it never
67
+ traps a loop open on its own inability to read or write, and it never blocks a
68
+ loop that has a real reason to keep working (the budget only counts MARKERS the
69
+ loop itself declared by firing this hook on a keep-alive turn).
70
+
71
+ ⚓ Kernel discipline (the litmus): a PURE verdict-adapter — imports only sibling
72
+ kernel modules (`loop_decide`, `config`, `durable_schema`), names no host /
73
+ driver, resolves every path via `SubstrateConfig.paths` (never `__file__`), and
74
+ carries no policy of its own (the threshold is `wait_marker_budget`'s
75
+ `max_markers`, handed in at the CLI).
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ import datetime as dt
81
+ import json
82
+ import os
83
+ import sys
84
+ from pathlib import Path
85
+ from typing import Optional
86
+
87
+ try:
88
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
89
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
90
+ except Exception:
91
+ pass
92
+
93
+ from dos import config as _config
94
+ from dos import durable_schema as _schema
95
+
96
+ # The durable-schema family + version every marker record carries (the §6 schema
97
+ # gate, byte-mirroring `posttool_sensor`). Bumped ONLY on a NON-additive shape
98
+ # change. A record tagged a non-additively-newer version is REFUSED at read (never
99
+ # best-effort-parsed into a forged count).
100
+ SCHEMA_FAMILY = "wait-marker"
101
+ WAIT_MARKER_SCHEMA = 1
102
+
103
+ # The directory the per-session marker tallies live under, beneath `.dos/`. A
104
+ # sibling of `posttool_sensor`'s `.dos/streams/` — keyed by the host-authored
105
+ # `session_id` (the Stop event carries a `session_id`, not a DOS run-id), exactly
106
+ # as the stream accumulator is.
107
+ MARKERS_DIRNAME = "markers"
108
+
109
+ # The closed op vocabulary the tally records. `MARKER` is one no-op (keep-alive) turn;
110
+ # `RESET` is a forward-delta / session-boundary marker that ZEROES the running count
111
+ # (docs/259 §Follow-up 2 — the `tool_stream` ADVANCING analogue: progress earns the
112
+ # loop a fresh budget). A new op is ADDITIVE in a closed vocabulary, so adding `RESET`
113
+ # does NOT bump `WAIT_MARKER_SCHEMA` (the `durable_schema` additive contract): an old
114
+ # reader simply skips an op it does not count, a new reader gives it meaning.
115
+ MARKER_OP = "MARKER"
116
+ RESET_OP = "RESET"
117
+
118
+
119
+ def _now_iso() -> str:
120
+ """Second-resolution UTC stamp for a marker record (the `intent_ledger` idiom)."""
121
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Boundary I/O — the ONE impure part: the session-scoped marker tally.
126
+ # Byte-mirrors `posttool_sensor`'s accumulator (which itself mirrors
127
+ # `intent_ledger`): fsync, O_APPEND, torn-tail tolerance, the §6 schema gate.
128
+ # A marker record is one append-only line; the COUNT is the number of valid lines.
129
+ # ---------------------------------------------------------------------------
130
+ def _safe_session_name(session_id: str) -> Optional[str]:
131
+ """The sanitized filename stem for a host-authored `session_id`, or None to skip.
132
+
133
+ Byte-identical to `posttool_sensor._safe_session_name`: `session_id` is a
134
+ distrusted host-authored token, so strip any path separators / `..` / drive
135
+ components (a path-traversal surface) and keep only the safe characters of a
136
+ normal session uuid. An empty/whitespace token (or one that sanitizes to empty)
137
+ returns None — no identity, no accumulator (the caller emits nothing rather than
138
+ writing to a junk path).
139
+ """
140
+ if not isinstance(session_id, str):
141
+ return None
142
+ safe = "".join(c for c in session_id if c.isalnum() or c in "-_")
143
+ return safe or None
144
+
145
+
146
+ def markers_dir_for(cfg: "_config.SubstrateConfig | None" = None) -> Path:
147
+ """The `.dos/markers/` directory under the active workspace. PURE path arithmetic.
148
+
149
+ Rides `cfg.paths.dot_dos` (the per-project `.dos/` home), the sibling of
150
+ `posttool_sensor`'s `.dos/streams/`. Never creates the dir — `record_marker` is
151
+ the only creator (the read-only-path discipline)."""
152
+ cfg = _config.ensure(cfg)
153
+ return cfg.paths.dot_dos / MARKERS_DIRNAME
154
+
155
+
156
+ def marker_path_for(
157
+ session_id: str, cfg: "_config.SubstrateConfig | None" = None
158
+ ) -> Optional[Path]:
159
+ """The `.dos/markers/<session_id>.jsonl` path for a session, or None if unusable.
160
+
161
+ Pure path arithmetic (the `posttool_sensor.stream_path_for` idiom): never creates
162
+ anything. Returns None when `session_id` sanitizes to empty (no safe filename) —
163
+ the caller treats that as "no accumulator," emitting nothing.
164
+ """
165
+ safe = _safe_session_name(session_id)
166
+ if safe is None:
167
+ return None
168
+ return markers_dir_for(cfg) / f"{safe}.jsonl"
169
+
170
+
171
+ def _marker_entry(*, reason: str | None = None, run_id: str | None = None) -> dict:
172
+ """The durable record for one emitted marker — schema-tagged, canonical. PURE.
173
+
174
+ Carries the §6 schema tag so a record written directly is self-declaring (the
175
+ `posttool_sensor._step_entry` posture). `reason` (the budget verdict's
176
+ operator-facing line) and `run_id` (the correlation-spine join key, when the
177
+ active env carries one) are ADDITIVE optional fields — present only when known —
178
+ so a record without them reads back identically and the schema version does NOT
179
+ bump (the `durable_schema` additive contract).
180
+ """
181
+ e: dict = {
182
+ **_schema.tag(SCHEMA_FAMILY, WAIT_MARKER_SCHEMA),
183
+ "op": MARKER_OP,
184
+ }
185
+ if reason:
186
+ e["reason"] = reason
187
+ if run_id:
188
+ e["run_id"] = run_id
189
+ return e
190
+
191
+
192
+ def _reset_entry(*, reason: str | None = None, run_id: str | None = None) -> dict:
193
+ """The durable record for one forward-delta / session-boundary RESET. PURE.
194
+
195
+ Identical shape to `_marker_entry` but `op:"RESET"` — a record that, on replay,
196
+ ZEROES the running no-op count (the `tool_stream` ADVANCING analogue, docs/259
197
+ §Follow-up 2). Carries the same §6 schema tag (so it is self-declaring) and the
198
+ same ADDITIVE optional `reason`/`run_id` fields; an additive new op never bumps the
199
+ schema version (the `durable_schema` contract).
200
+ """
201
+ e: dict = {
202
+ **_schema.tag(SCHEMA_FAMILY, WAIT_MARKER_SCHEMA),
203
+ "op": RESET_OP,
204
+ }
205
+ if reason:
206
+ e["reason"] = reason
207
+ if run_id:
208
+ e["run_id"] = run_id
209
+ return e
210
+
211
+
212
+ def record_marker(
213
+ session_id: str,
214
+ cfg: "_config.SubstrateConfig | None" = None,
215
+ *,
216
+ path: Path | None = None,
217
+ reason: str | None = None,
218
+ run_id: str | None = None,
219
+ ) -> None:
220
+ """Append ONE marker record to the session's tally and `fsync` it.
221
+
222
+ Copies `posttool_sensor.append_step`'s durability idiom EXACTLY (itself a copy of
223
+ `intent_ledger.append`): stamp the record (the §6 schema tag + a `ts`), write one
224
+ canonical-JSON line + newline through `os.open(O_WRONLY|O_APPEND|O_CREAT)` +
225
+ `os.write` + `os.fsync` + `os.close`, so the record is durable before this
226
+ returns and the append is atomic w.r.t. any other appender at the OS level.
227
+ `mkdir(parents=True)` the markers dir lazily (the only creator). `path` overrides
228
+ the resolved location (tests).
229
+
230
+ Raises on an unusable `session_id` (no `path` and the session sanitizes to
231
+ empty) — the CLI wraps this whole call in a fail-safe try/except (advisory: never
232
+ block a real workflow on the sensor's own write failure), so a raise here degrades
233
+ to "emit nothing," never a crashed turn.
234
+ """
235
+ _append_record(
236
+ _marker_entry(reason=reason, run_id=run_id),
237
+ session_id,
238
+ cfg,
239
+ path=path,
240
+ what="record_marker",
241
+ )
242
+
243
+
244
+ def record_reset(
245
+ session_id: str,
246
+ cfg: "_config.SubstrateConfig | None" = None,
247
+ *,
248
+ path: Path | None = None,
249
+ reason: str | None = None,
250
+ run_id: str | None = None,
251
+ ) -> None:
252
+ """Append ONE forward-delta RESET record to the session's tally and `fsync` it.
253
+
254
+ The §Follow-up 2 zeroing event: a forward delta (a commit, a real tool result, or
255
+ a host re-entering a fresh wait phase) appends a `RESET` record, and `marker_count`
256
+ replays the count as markers-AFTER-the-last-reset — so progress earns the loop a
257
+ fresh budget (the `tool_stream` ADVANCING analogue). Same durability idiom as
258
+ `record_marker` (the `intent_ledger`/`posttool_sensor` `O_APPEND`+`fsync` append):
259
+ the reset is durable and atomic at the OS level before this returns, never a
260
+ truncation of the append-only log.
261
+
262
+ Raises on an unusable `session_id` (no `path` and the session sanitizes to empty);
263
+ the CLI wraps the call in a fail-safe try/except (advisory: a reset we could not
264
+ persist must not crash the turn — it degrades to "no reset," which leaves the count
265
+ HIGHER, the conservative refuse-more direction for a cost guard).
266
+ """
267
+ _append_record(
268
+ _reset_entry(reason=reason, run_id=run_id),
269
+ session_id,
270
+ cfg,
271
+ path=path,
272
+ what="record_reset",
273
+ )
274
+
275
+
276
+ def _append_record(
277
+ entry: dict,
278
+ session_id: str,
279
+ cfg: "_config.SubstrateConfig | None" = None,
280
+ *,
281
+ path: Path | None = None,
282
+ what: str = "record",
283
+ ) -> None:
284
+ """The shared durable-append both `record_marker` and `record_reset` ride. Impure.
285
+
286
+ Stamp the (already schema-tagged) record with a `ts`, write one canonical-JSON line
287
+ + newline through `os.open(O_WRONLY|O_APPEND|O_CREAT)` + `os.write` + `os.fsync` +
288
+ `os.close` (the `posttool_sensor.append_step` / `intent_ledger.append` idiom), so
289
+ the record is durable before this returns and the append is atomic w.r.t. any other
290
+ appender at the OS level. `mkdir(parents=True)` the markers dir lazily (the only
291
+ creator). `path` overrides the resolved location (tests). Raises (with `what` named)
292
+ on an unusable `session_id` and no explicit `path`.
293
+ """
294
+ p = path or marker_path_for(session_id, cfg)
295
+ if p is None:
296
+ raise ValueError(f"{what} needs a usable session_id or an explicit path")
297
+ entry.setdefault("ts", _now_iso())
298
+ line = json.dumps(entry, sort_keys=True, default=str, ensure_ascii=False) + "\n"
299
+ p.parent.mkdir(parents=True, exist_ok=True)
300
+ fd = os.open(str(p), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)
301
+ try:
302
+ os.write(fd, line.encode("utf-8"))
303
+ os.fsync(fd)
304
+ finally:
305
+ os.close(fd)
306
+
307
+
308
+ def marker_count(
309
+ session_id: str,
310
+ cfg: "_config.SubstrateConfig | None" = None,
311
+ *,
312
+ path: Path | None = None,
313
+ understands: int = WAIT_MARKER_SCHEMA,
314
+ ) -> int:
315
+ """Replay the session's tally into the no-op COUNT SINCE THE LAST RESET. The read-side.
316
+
317
+ The count is "the number of readable MARKER records that appear AFTER the last
318
+ readable RESET record" (docs/259 §Follow-up 1+2): a single forward pass where a
319
+ MARKER increments and a RESET zeroes. With NO RESET in the file (every host today),
320
+ this is byte-identical to the old "count all MARKERs" — so the shipped `dos hook
321
+ marker` behavior is unchanged for any current tally; the reset only bites once a
322
+ forward-delta RESET is written.
323
+
324
+ Two distrust postures layered, byte-mirroring `posttool_sensor.read_stream` /
325
+ `intent_ledger.read_all`:
326
+
327
+ * **Torn-tail tolerance** — an unparseable line (a crash mid-append, or a
328
+ mid-file corrupt line) is skipped: a half-written record is "didn't happen."
329
+ For a MARKER this UNDER-counts (admit one more marker than strictly emitted);
330
+ for a RESET this means the reset "didn't happen" so the count stays HIGHER —
331
+ BOTH are the same conservative direction (refuse one MORE no-op turn, never one
332
+ fewer), the right bias for an advisory cost guard. A torn RESET can never erase
333
+ a real marker count it failed to fully write.
334
+ * **Schema gate** (§6) — a record whose `schema` tag is a non-additively-newer
335
+ version than `understands` is SKIPPED (a record this kernel is too old to
336
+ read can never fabricate OR erase a count — a too-new RESET does not zero). An
337
+ UNTAGGED (legacy) record is read permissively (the `durable_schema.UNTAGGED`
338
+ tolerant side); a WRONG_FAMILY record (a foreign line) is skipped. An unknown
339
+ op (neither MARKER nor RESET) is ignored, count unchanged.
340
+
341
+ Returns 0 when the file is absent (no markers emitted yet — the budget's fresh
342
+ floor). `understands` is injectable so a test can simulate an OLD reader meeting a
343
+ NEW record.
344
+ """
345
+ p = path or marker_path_for(session_id, cfg)
346
+ if p is None or not p.exists():
347
+ return 0
348
+ try:
349
+ raw = p.read_text(encoding="utf-8", errors="replace")
350
+ except OSError:
351
+ return 0
352
+ count = 0
353
+ for line in raw.splitlines():
354
+ s = line.strip()
355
+ if not s:
356
+ continue
357
+ try:
358
+ obj = json.loads(s)
359
+ except json.JSONDecodeError:
360
+ # Torn final / corrupt mid-file line → "didn't happen". For a MARKER this
361
+ # under-counts; for a RESET the zeroing is skipped so the count stays
362
+ # higher — both the safe (refuse-more) direction for a cost guard.
363
+ continue
364
+ if not isinstance(obj, dict):
365
+ continue
366
+ # The §6 schema gate. READABLE/UNTAGGED proceed; UNREADABLE_NEWER and
367
+ # WRONG_FAMILY are skipped (a too-new/foreign record never forges a count and
368
+ # — critically for a RESET — never erases one).
369
+ v = _schema.classify(obj, family=SCHEMA_FAMILY, understands=understands)
370
+ if v.readability not in (
371
+ _schema.Readability.READABLE,
372
+ _schema.Readability.UNTAGGED,
373
+ ):
374
+ continue
375
+ op = obj.get("op")
376
+ if op == MARKER_OP:
377
+ count += 1
378
+ elif op == RESET_OP:
379
+ # A forward-delta reset zeroes the running no-op count — progress earns the
380
+ # loop a fresh budget (the `tool_stream` ADVANCING analogue).
381
+ count = 0
382
+ # else: an unknown/absent op is not a counted no-op turn and not a reset.
383
+ return count
384
+
385
+
386
+ # The session-boundary RESET (docs/259 §Follow-up 2) is now LIVE: `record_reset`
387
+ # appends an `op:"RESET"` record and `marker_count` replays the count as
388
+ # markers-after-the-last-reset, so a forward delta (or a host re-entering a fresh wait
389
+ # phase) zeroes the tally — the `tool_stream` ADVANCING analogue. The pure verdict over
390
+ # the resulting count is `noop_streak.classify` (the generalization of
391
+ # `wait_marker_budget` off "markers emitted" onto "no-op turns since the last forward
392
+ # delta"). What is STILL explicit (not auto-derived) is the reset TRIGGER: a host fires
393
+ # `dos hook marker --reset` on a forward-progress hook (SessionStart/UserPromptSubmit)
394
+ # or after a commit. Auto-deriving a RESET from a live git/journal delta (pulling
395
+ # `git_delta` into the marker boundary, the `liveness` evidence reader) is the remaining
396
+ # future step — it would close the loop fully but is a larger change than this cut.