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/exec_capability.py ADDED
@@ -0,0 +1,256 @@
1
+ """XCAP — the arbitrary-code-execution capability classifier: *a SHAPE, not a word.*
2
+
3
+ docs/224 — idea **B1** from the Claude Code source audit (docs/189). The audit
4
+ found CC's `dangerousPatterns.ts`: a list that identifies which *allow-rule
5
+ prefixes* hand the model **arbitrary code execution** — `python`, `node`, `bash`,
6
+ `ssh`, `npx`, `eval`, … An allow-rule like `Bash(python:*)` is not "a python
7
+ command"; it is *a way to run anything at all*, because `python -c '<any code>'`
8
+ escapes every narrower gate. CC strips such rules at auto-mode entry. The insight
9
+ DOS lifts is the docs/158 law — **a capability is a SHAPE, not a word** — applied to
10
+ *command auditing* rather than output classification: you do not scan a command for
11
+ the substring "dangerous"; you ask "does the program it invokes grant arbitrary
12
+ execution?", matched against a closed, declared capability list.
13
+
14
+ This is a **pure classifier leaf**, the `terminal_error`/`arg_provenance` detector
15
+ shape (a pure verdict over already-gathered bytes), NOT an admission predicate. The
16
+ distinction is deliberate and load-bearing:
17
+
18
+ * `self_modify` is an *admission* predicate — it answers "may this LANE (a
19
+ file-tree request) be leased?" over a tree. It plugs into the arbiter's
20
+ conjunction.
21
+ * XCAP answers "does this COMMAND grant arbitrary exec?" over a command string.
22
+ DOS has no permission-rule allow-list surface (CC's home for this), so XCAP is
23
+ not an arbiter predicate — it is a classifier the **consumer** (`pretool_sensor`,
24
+ the PRE-moment PEP, docs/191) consults to attach an advisory signal to a
25
+ proposed Bash call. The verdict REPORTS the capability; the consumer decides
26
+ what to do with it (today: a WARN on the intervention ladder; a host driver may
27
+ escalate). Advisory by default — the docs/143 −9 pp lesson: spurious disruption
28
+ is the expensive mistake, so a capability *observation* never auto-denies on its
29
+ own (a deny is a host's explicit, --force-overridable choice).
30
+
31
+ **Byte-clean / SHAPE-not-word, made precise.** XCAP reads the *program token* — the
32
+ first word of the command (after stripping an `env VAR=…` / `sudo` prefix) — and
33
+ compares it to a closed set. It does NOT regex the whole command for scary
34
+ substrings (a path named `my_eval_helper.txt` is not an `eval`; a comment
35
+ mentioning `python` is not a python invocation). Matching the invoked-program SHAPE,
36
+ not a word anywhere in the string, is what keeps it from the forgeable-keyword trap
37
+ the docs/158 detector-design guide warns against. The command bytes are the agent's
38
+ *proposal* (agent-authored), so XCAP is a check on a PROPOSED capability, not a
39
+ distrust-of-result verdict — it belongs at PRE (before the call runs), exactly where
40
+ `pretool_sensor` already lives.
41
+
42
+ **Domain-free / mechanism-policy split.** The mechanism is "tokenize the command,
43
+ look up the program in the capability set." The policy — *which programs grant
44
+ arbitrary exec* — is data, defaulted to CC's `CROSS_PLATFORM_CODE_EXEC` list and
45
+ declarable per-workspace in `dos.toml [exec_capability]`. A host that ships an
46
+ internal interpreter adds one line of data; the kernel's matching logic never
47
+ changes. The classifier never branches on a host name.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import enum
53
+ from dataclasses import dataclass, field
54
+
55
+
56
+ class Capability(str, enum.Enum):
57
+ """What execution capability a command grants — the typed verdict.
58
+
59
+ `str`-valued so it round-trips through a CLI stdout token / exit-code map
60
+ (the `liveness.Liveness` / `breaker.BreakerState` idiom).
61
+ """
62
+
63
+ GRANTS_ARBITRARY_EXEC = "GRANTS_ARBITRARY_EXEC" # invokes an interpreter/shell/remote-exec
64
+ BOUNDED = "BOUNDED" # the invoked program is not a known arbitrary-exec entry point
65
+ EMPTY = "EMPTY" # no program token to classify (blank command)
66
+
67
+ def __str__(self) -> str: # pragma: no cover - trivial
68
+ return self.value
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # The capability set — CC's CROSS_PLATFORM_CODE_EXEC, lifted verbatim. Each entry
73
+ # is a PROGRAM whose mere invocation grants arbitrary code execution (an
74
+ # interpreter that takes `-c`/`-e`, a shell, a package-runner that runs scripts, a
75
+ # remote-exec wrapper). This is the SHAPE the classifier matches the program token
76
+ # against — declared as data so a host extends it in `dos.toml [exec_capability]`,
77
+ # the closed-config-as-data pattern (`[lanes]`/`[reasons]`/`[liveness]`).
78
+ # ---------------------------------------------------------------------------
79
+ CROSS_PLATFORM_CODE_EXEC: frozenset[str] = frozenset({
80
+ # Interpreters — each takes inline code (`python -c`, `node -e`, `ruby -e`, …).
81
+ "python", "python3", "python2", "node", "deno", "tsx", "ruby", "perl", "php", "lua",
82
+ # Package runners — run arbitrary project scripts / fetched packages.
83
+ "npx", "bunx", "npm", "yarn", "pnpm", "bun",
84
+ # Shells — the most direct arbitrary-exec entry point.
85
+ "bash", "sh", "zsh", "fish",
86
+ # Exec built-ins / wrappers that run an arbitrary argument as a program.
87
+ "eval", "exec", "xargs",
88
+ # Remote / privilege wrappers — arbitrary command on another host / as root.
89
+ "ssh", "sudo",
90
+ })
91
+
92
+ # Prefix tokens that wrap a REAL program without being the capability themselves:
93
+ # `env FOO=bar python …` and `sudo python …` both invoke `python`. We strip these
94
+ # (and any `VAR=value` assignments) to find the program token actually invoked.
95
+ # NOTE `sudo` is ALSO in the capability set (it grants root) — so `sudo rm` is
96
+ # GRANTS_ARBITRARY_EXEC via the sudo entry, while `sudo python` is caught either
97
+ # way; stripping it lets us also see the wrapped `python`. The verdict fires on the
98
+ # FIRST capability hit, so wrapping never hides a capability.
99
+ _WRAPPER_TOKENS: frozenset[str] = frozenset({"env", "sudo", "command", "nice", "nohup", "time"})
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class ExecCapabilityPolicy:
104
+ """The capability set to match against — policy, not mechanism.
105
+
106
+ The mechanism (tokenize, strip wrappers, look up) is the kernel's; the SET of
107
+ arbitrary-exec programs is data. Defaults to `CROSS_PLATFORM_CODE_EXEC` (CC's
108
+ list); a workspace declares additions in `dos.toml [exec_capability]`
109
+ (`extra = ["myinterp"]`), the same closed-config-as-data on-ramp as `[reasons]`.
110
+
111
+ programs — the closed set of program tokens that grant arbitrary execution.
112
+ Matched case-insensitively against the invoked program token (a
113
+ program's basename, lower-cased — `/usr/bin/python3` → `python3`).
114
+ """
115
+
116
+ programs: frozenset[str] = field(default_factory=lambda: CROSS_PLATFORM_CODE_EXEC)
117
+
118
+ def with_extra(self, extra) -> "ExecCapabilityPolicy":
119
+ """A new policy with `extra` program tokens added (the host on-ramp)."""
120
+ more = frozenset(str(p).strip().lower() for p in (extra or ()) if str(p).strip())
121
+ return ExecCapabilityPolicy(programs=self.programs | more)
122
+
123
+
124
+ DEFAULT_POLICY = ExecCapabilityPolicy()
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class ExecCapabilityVerdict:
129
+ """The classifier's verdict + the evidence (the matched program), echoed back.
130
+
131
+ `capability` is the typed `Capability`. `program` is the invoked program token
132
+ the classifier extracted (the basename, lower-cased) — None for an empty
133
+ command. `reason` is the one-line operator-facing summary. The matched program
134
+ is carried so a consumer can name *what* grants the capability (legible
135
+ distrust — "GRANTS_ARBITRARY_EXEC via `python`", not a bare flag).
136
+ """
137
+
138
+ capability: Capability
139
+ program: str | None
140
+ reason: str
141
+
142
+ @property
143
+ def grants_arbitrary_exec(self) -> bool:
144
+ return self.capability is Capability.GRANTS_ARBITRARY_EXEC
145
+
146
+ def to_dict(self) -> dict:
147
+ return {
148
+ "capability": self.capability.value,
149
+ "program": self.program,
150
+ "reason": self.reason,
151
+ }
152
+
153
+
154
+ def _program_token(command: str) -> str | None:
155
+ """Extract the program token a command invokes. PURE — a tokenizer, not a shell.
156
+
157
+ The SHAPE extraction: split off the first word, skipping leading `VAR=value`
158
+ assignments and benign wrappers (`env`, `nice`, `time`, …) to find the program
159
+ that is actually run. Returns the program's BASENAME, lower-cased
160
+ (`/usr/bin/python3` → `python3`, `PYTHON` → `python`), or None for a blank
161
+ command. Deliberately simple — it reads the invoked-program shape, never the
162
+ whole command (matching a word anywhere would be the forgeable-keyword trap).
163
+ """
164
+ if not command or not command.strip():
165
+ return None
166
+ # Walk leading tokens, skipping VAR=value assignments and wrapper words, until
167
+ # we hit the real program. A wrapper that is ALSO a capability (`sudo`) is
168
+ # handled by the caller scanning all leading capability hits — here we just find
169
+ # the first non-wrapper, non-assignment token to report as the program.
170
+ for raw in command.strip().split():
171
+ tok = raw.strip()
172
+ if not tok:
173
+ continue
174
+ if "=" in tok and not tok.startswith("="):
175
+ # A leading `VAR=value` assignment (only before the program) — skip.
176
+ head = tok.split("=", 1)[0]
177
+ if head and all(c.isalnum() or c == "_" for c in head):
178
+ continue
179
+ base = tok.replace("\\", "/").rsplit("/", 1)[-1].lower()
180
+ if base in _WRAPPER_TOKENS:
181
+ continue # a wrapper — keep walking to the wrapped program
182
+ return base
183
+ return None
184
+
185
+
186
+ def _leading_tokens(command: str) -> list[str]:
187
+ """The leading program-ish tokens (basenames, lower-cased) up to the first
188
+ non-wrapper program, INCLUDING any wrapper that is itself a capability. PURE.
189
+
190
+ Used so a command whose WRAPPER is a capability (`sudo rm`) fires on the sudo
191
+ entry even though the reported program is the wrapped `rm`. Returns the wrappers
192
+ seen plus the final program token, in order.
193
+ """
194
+ out: list[str] = []
195
+ for raw in command.strip().split():
196
+ tok = raw.strip()
197
+ if not tok:
198
+ continue
199
+ if "=" in tok and not tok.startswith("="):
200
+ head = tok.split("=", 1)[0]
201
+ if head and all(c.isalnum() or c == "_" for c in head):
202
+ continue
203
+ base = tok.replace("\\", "/").rsplit("/", 1)[-1].lower()
204
+ out.append(base)
205
+ if base not in _WRAPPER_TOKENS:
206
+ break # reached the real program — stop
207
+ return out
208
+
209
+
210
+ def classify_command(
211
+ command: str, policy: ExecCapabilityPolicy = DEFAULT_POLICY
212
+ ) -> ExecCapabilityVerdict:
213
+ """Classify the execution capability a command grants. PURE — no I/O.
214
+
215
+ The ladder:
216
+ 1. EMPTY — no program token (a blank command). Nothing to classify.
217
+ 2. GRANTS_ARBITRARY_EXEC — the invoked program (or a capability wrapper like
218
+ `sudo` in front of it) is in the capability set. `python -c …`, `bash -c …`,
219
+ `npx …`, `ssh host …`, `sudo …` — each is a way to run arbitrary code.
220
+ 3. BOUNDED — the invoked program is not a known arbitrary-exec entry point
221
+ (`ls`, `cat`, `git status`, `grep`, …). This is NOT a safety guarantee
222
+ (`git` can run hooks; the audit notes `git`/`gh`/`curl` are ant-only
223
+ additions) — only "not a member of the declared arbitrary-exec set." A host
224
+ that wants those flagged adds them to `[exec_capability]`.
225
+
226
+ Matches the SHAPE (the invoked program), never a substring of the command — so a
227
+ file named `eval.txt` or a comment mentioning `python` does not trip it.
228
+ """
229
+ program = _program_token(command)
230
+ if program is None:
231
+ return ExecCapabilityVerdict(
232
+ capability=Capability.EMPTY,
233
+ program=None,
234
+ reason="empty command — no program token to classify",
235
+ )
236
+ # Scan the leading tokens (wrappers + the program) for the FIRST capability hit,
237
+ # so `sudo python` / `env X=1 bash` fire on whichever capability appears.
238
+ for tok in _leading_tokens(command):
239
+ if tok in policy.programs:
240
+ return ExecCapabilityVerdict(
241
+ capability=Capability.GRANTS_ARBITRARY_EXEC,
242
+ program=program,
243
+ reason=(
244
+ f"the command invokes {tok!r}, an arbitrary-code-execution entry "
245
+ f"point — it can run any code, escaping a narrower per-command gate "
246
+ f"(GRANTS_ARBITRARY_EXEC)"
247
+ ),
248
+ )
249
+ return ExecCapabilityVerdict(
250
+ capability=Capability.BOUNDED,
251
+ program=program,
252
+ reason=(
253
+ f"the command invokes {program!r}, not a known arbitrary-exec entry point "
254
+ f"— bounded (NOT a safety guarantee; only 'not in the declared exec set')"
255
+ ),
256
+ )
dos/export_cursor.py ADDED
@@ -0,0 +1,143 @@
1
+ """export_cursor — the resumable drain offset for `dos export` (docs/266 Phase 4).
2
+
3
+ The verdict exporter (`dos.exporter` + the `file`/`statsd`/`otlp` drivers) drains the
4
+ verdict journal forward; the cursor is *how far it got*. It is the journal's OWN
5
+ monotonic `seq` (nothing fabricated — the docs/262 spine key, the `--since` offset
6
+ Phases 1–3 already carry on every `ExportResult.cursor`), persisted to a tiny file so a
7
+ repeated `dos export` auto-resumes WITHOUT the operator threading the number forward by
8
+ hand.
9
+
10
+ Why a separate module, not `exporter.py`
11
+ =========================================
12
+
13
+ `exporter.py` is the kernel SEAM — a pure Protocol + resolver + fail-soft wrapper, the
14
+ `notify.py` shape, deliberately import-light and (apart from entry-point discovery)
15
+ I/O-free. The cursor is WAL-ADJACENT STATE — a one-line file read/written at the drain
16
+ boundary, exactly the kind of thing `verdict_journal.py` (a substrate data module) owns,
17
+ not the pure seam. So it lives here, resolved as a sibling of the verdict journal (the
18
+ `verdict_journal._default_journal_path` idiom), with the same fail-soft posture: a read
19
+ that cannot parse returns 0 (drain from the start), a write that fails is swallowed (a
20
+ cursor-persistence failure must never crash the drain — the `verdict_journal.record`
21
+ contract, inherited).
22
+
23
+ Host-cadence-free (the kernel ships no daemon)
24
+ ==============================================
25
+
26
+ The cursor makes the drain RESUMABLE; it does NOT make it a daemon. A fleet drives the
27
+ *cadence* — `dos export --to file --since auto` on a `/loop`/cron tick reads the cursor,
28
+ ships the new tail, writes the cursor back. The kernel owns the OFFSET, the host owns the
29
+ CLOCK (the `dos notify` / `dos top` posture: no `while True` in the kernel). The
30
+ `--follow` convenience verb is a BOUNDED foreground loop (it always terminates on a max
31
+ iteration / a quiet streak), never an unbounded blocker.
32
+
33
+ The file is `.dos/export-cursor` (docs/266 §4) — a sibling of the journal it tracks, one
34
+ line: the highest `seq` shipped. A per-transport suffix keeps two destinations
35
+ (a file shipper + an OTLP collector) from clobbering each other's progress.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import os
41
+ from pathlib import Path
42
+
43
+ from dos import config as _config
44
+
45
+ # The workspace-neutral env override (parallel to DISPATCH_VERDICT_JOURNAL_PATH). Points
46
+ # at the cursor FILE (or its stem when a per-transport suffix is appended).
47
+ _ENV_PATH = "DISPATCH_EXPORT_CURSOR_PATH"
48
+
49
+ # The sentinel a CLI passes for `--since` to mean "read the persisted cursor" rather than
50
+ # an explicit integer. Kept here so the verb and the helpers agree on the spelling.
51
+ AUTO = "auto"
52
+
53
+
54
+ def _default_cursor_path() -> Path:
55
+ """The active workspace's export-cursor file — `.dos/export-cursor`, a journal sibling.
56
+
57
+ Mirrors `verdict_journal._default_journal_path`: derive it from the resolved verdict
58
+ journal (so a `DISPATCH_VERDICT_JOURNAL_PATH` redirect carries the cursor along), or
59
+ fall back to a lane-journal sibling when the layout field is unset."""
60
+ paths = _config.active().paths
61
+ vj = getattr(paths, "verdict_journal", None)
62
+ base = Path(vj) if vj is not None else Path(paths.lane_journal).with_name(
63
+ "verdict-journal.jsonl")
64
+ return base.with_name("export-cursor")
65
+
66
+
67
+ def cursor_path(path: Path | None = None, *, transport: str = "") -> Path:
68
+ """Resolve the cursor file: explicit arg › env override › `.dos/export-cursor`.
69
+
70
+ `transport` (when given) is appended as a `.<transport>` suffix so distinct
71
+ destinations track independent progress — `.dos/export-cursor.file` vs
72
+ `.dos/export-cursor.otlp` — and a `dos export --to file` drain never advances the
73
+ cursor an OTLP drain reads. Re-read each call so a test that sets the env var after
74
+ import still redirects (the lane-journal idiom)."""
75
+ if path is not None:
76
+ base = Path(path)
77
+ else:
78
+ env = os.environ.get(_ENV_PATH)
79
+ base = Path(env) if env else _default_cursor_path()
80
+ if transport:
81
+ return base.with_name(f"{base.name}.{transport}")
82
+ return base
83
+
84
+
85
+ def read_cursor(path: Path | None = None, *, transport: str = "") -> int:
86
+ """The persisted cursor (highest seq shipped), or 0 when none/unreadable. FAIL-SOFT.
87
+
88
+ A missing file, an empty file, or a non-integer body all return 0 — "drain from the
89
+ start," the safe default (re-shipping a few events is harmless and idempotent for a
90
+ file/statsd/otlp sink; never advancing past unread events is the failure to avoid).
91
+ Never raises (the `verdict_journal.read_all` posture)."""
92
+ p = cursor_path(path, transport=transport)
93
+ try:
94
+ raw = p.read_text(encoding="utf-8").strip()
95
+ except OSError:
96
+ return 0
97
+ if not raw:
98
+ return 0
99
+ try:
100
+ return int(raw)
101
+ except ValueError:
102
+ return 0
103
+
104
+
105
+ def write_cursor(value: int, path: Path | None = None, *, transport: str = "") -> bool:
106
+ """Persist `value` as the new cursor. Returns True on success, False on failure. FAIL-SOFT.
107
+
108
+ The dir is created on demand (`mkdir(parents=True)`, like the journal writers). A
109
+ write failure (full disk, permission) is swallowed and reported as False — a
110
+ cursor-persistence failure must never crash the drain that produced the events (the
111
+ `verdict_journal.record` fail-soft contract). A negative/zero value is written as-is
112
+ (0 is the honest "nothing shipped yet" cursor)."""
113
+ p = cursor_path(path, transport=transport)
114
+ try:
115
+ p.parent.mkdir(parents=True, exist_ok=True)
116
+ p.write_text(f"{int(value)}\n", encoding="utf-8")
117
+ return True
118
+ except Exception:
119
+ return False
120
+
121
+
122
+ def resolve_since(since_arg: str, *, path: Path | None = None, transport: str = "") -> tuple[int, bool]:
123
+ """Turn a `--since` value into (seq, auto). Pure-ish (reads the cursor only for AUTO).
124
+
125
+ Returns `(seq, auto)` where `auto` is True iff the operator passed the `AUTO` sentinel
126
+ (so the verb knows to WRITE the cursor back after a successful drain). The mapping:
127
+
128
+ * "" / missing → (0, False) — no slice, drain everything; do NOT persist
129
+ * "auto" → (read_cursor(), True) — resume from the persisted cursor,
130
+ and persist the new high-water mark after the drain
131
+ * an integer string → (int, False) — explicit one-shot offset; do NOT persist
132
+
133
+ A non-integer, non-`auto` value raises ValueError (an operator typo, surfaced at the
134
+ boundary — the `resolve_notifier` loud-on-bad-input rule). So a `/loop` runs
135
+ `dos export --since auto` and the cursor threads itself; a human debugging runs
136
+ `--since 42` for a one-shot without disturbing the persisted offset.
137
+ """
138
+ s = (since_arg or "").strip()
139
+ if not s:
140
+ return (0, False)
141
+ if s.lower() == AUTO:
142
+ return (read_cursor(path, transport=transport), True)
143
+ return (int(s), False) # raises ValueError on a bad token — caught at the boundary