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/verdict_cli.py ADDED
@@ -0,0 +1,189 @@
1
+ """Generic registry → CLI wiring for verdict verbs (docs/86 §2, the modular seam).
2
+
3
+ The point: a consumer (`cli.py`) hard-wires `dos verify`/`dos liveness`/… today —
4
+ one `cmd_X` + `add_parser` + `set_defaults` per verb. That is the coupling the
5
+ registry exists to dissolve. This module is the **generic dispatcher**: it reads
6
+ `verdicts.all_specs()` and builds a subparser + a uniform run-handler for every
7
+ verb that carries a CLI adapter, so:
8
+
9
+ adding a verb = one register(...) (+ its CLI adapter) — and `cli.py` is
10
+ edited ONCE (a single `verdict_cli.attach(sub, ...)` call), NEVER per verb.
11
+
12
+ That single-line hook into `cli.py` is the only edit the real CLI needs; it is
13
+ deliberately deferred until the (currently hot) `cli.py` settles, so this module
14
+ is built and tested STANDALONE first. Everything here works against a plain
15
+ `argparse` parser with no dependency on `cli.py`'s internals — the consumer
16
+ injects how it resolves a `SubstrateConfig` via `config_resolver`, so this module
17
+ stays decoupled from `cli.py`'s workspace plumbing.
18
+
19
+ Layering: this is a CONSUMER, like `cli.py` / `dos_mcp` — it imports the registry
20
+ and the verbs; nothing under `src/dos/*.py` that is a verb imports it. The
21
+ verb-agnostic CORE of each spec (name/classify/summary) lives in `verdicts.py`
22
+ (no I/O); the CLI ADAPTER (argument flags, the git-diff gather, exit codes) is a
23
+ consumer concern and lives HERE — the mechanism-vs-consumer split the kernel
24
+ draws everywhere. `_ensure_cli_specs()` enriches the registry's core specs with
25
+ their adapters the first time the dispatcher is built, confining the boundary I/O
26
+ (git) to this module.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import subprocess
34
+ from pathlib import Path
35
+ from typing import Any, Callable
36
+
37
+ from . import verdicts, scope
38
+
39
+
40
+ _GIT_TIMEOUT_S = 15
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Boundary I/O — the git-diff reader (the `git_delta` mold: the subprocess lives
45
+ # in the consumer, the pure classifier gets an already-gathered set).
46
+ # ---------------------------------------------------------------------------
47
+ def _git_diff_names(base: str, head: str, *, root: Path | str) -> frozenset[str]:
48
+ """`git diff --name-only <base>..<head>` over `root`, as a frozenset of paths.
49
+
50
+ Degrades to an empty set on any failure (non-git dir, bad ref, missing git,
51
+ timeout, non-zero exit) — the same fail-safe as `git_delta.commits_since`: a
52
+ scope verdict in a repo with no diff is the honest "empty footprint", never a
53
+ crash.
54
+ """
55
+ try:
56
+ raw = subprocess.run(
57
+ ["git", "diff", "--name-only", f"{base}..{head}"],
58
+ cwd=str(root), capture_output=True, text=True,
59
+ check=False, timeout=_GIT_TIMEOUT_S,
60
+ )
61
+ except (OSError, subprocess.TimeoutExpired):
62
+ return frozenset()
63
+ if raw.returncode != 0:
64
+ return frozenset()
65
+ return frozenset(ln.strip() for ln in raw.stdout.splitlines() if ln.strip())
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # The CLI adapter for `scope` (a consumer concern — args, gather, exit codes).
70
+ # Lives here, not in scope.py (pure) or verdicts.py (no I/O), so the kernel verb
71
+ # stays pure and the registry stays light. A future verb adds its adapter the
72
+ # same way; the dispatcher below is unchanged.
73
+ # ---------------------------------------------------------------------------
74
+ def _scope_add_args(p: argparse.ArgumentParser) -> None:
75
+ p.add_argument("--lane", required=True,
76
+ help="the lane the diff is stamped against (a key in [lanes])")
77
+ p.add_argument("--base", default="HEAD~1",
78
+ help="base ref of the diff range (default HEAD~1)")
79
+ p.add_argument("--head", default="HEAD",
80
+ help="head ref of the diff range (default HEAD)")
81
+
82
+
83
+ def _scope_gather(args: argparse.Namespace, cfg: Any) -> scope.ScopeEvidence:
84
+ """Build `ScopeEvidence` from CLI args + config: the touched-file set from
85
+ `git diff`, the lane tree from `cfg.lanes.trees[lane]` (generic `("**/*",)`
86
+ if the lane is undeclared — the no-plan floor)."""
87
+ root = cfg.paths.root
88
+ touched = _git_diff_names(args.base, args.head, root=root)
89
+ lane_tree = tuple(cfg.lanes.trees.get(args.lane, ("**/*",)))
90
+ return scope.ScopeEvidence(touched_files=touched, lane_tree=lane_tree, lane=args.lane)
91
+
92
+
93
+ # Exit codes per scope verdict (distinct from argparse's 2=usage and liveness's
94
+ # 3/4): a clean footprint is 0; a violation is non-zero so a shell `if dos scope`
95
+ # branches on it. SCOPE_CREEP < WRONG_TARGET by severity.
96
+ _SCOPE_EXIT = {"IN_SCOPE": 0, "SCOPE_CREEP": 5, "WRONG_TARGET": 6}
97
+
98
+
99
+ _CLI_SPECS_READY = False
100
+
101
+
102
+ def _ensure_cli_specs() -> None:
103
+ """Enrich the registry's core specs with their CLI adapters (idempotent).
104
+
105
+ `verdicts.py` seed-registers the verb-agnostic core (name/classify/…, no I/O).
106
+ Here we add the consumer-side adapter — argument flags, the git-diff gather,
107
+ exit codes — by re-registering with `replace=True`. Confines git I/O to this
108
+ module and keeps the registry the single source of truth for *which* verbs
109
+ exist while letting each consumer own *how* it surfaces them.
110
+ """
111
+ global _CLI_SPECS_READY
112
+ if _CLI_SPECS_READY:
113
+ return
114
+ core = verdicts.get("scope")
115
+ verdicts.register(verdicts.VerdictSpec(
116
+ name=core.name, classify=core.classify, summary=core.summary,
117
+ distrusts=core.distrusts, reviewed=core.reviewed,
118
+ add_arguments=_scope_add_args,
119
+ gather=_scope_gather,
120
+ exit_codes=dict(_SCOPE_EXIT),
121
+ ), replace=True)
122
+ # NOTE: `liveness` already has a bespoke `cmd_liveness` in cli.py (with the
123
+ # journal-rung logic); migrating it onto this dispatcher is a follow-up to do
124
+ # against the real cli.py, not blind here. `verify` waits on ShipVerdict
125
+ # harmonization (it has no typed `.verdict` yet). So `scope` is the first verb
126
+ # surfaced through the generic path — the proof the wiring is real.
127
+ _CLI_SPECS_READY = True
128
+
129
+
130
+ def _render(verdict_obj: Any, output: str) -> None:
131
+ if output == "json":
132
+ print(json.dumps(verdict_obj.to_dict(), indent=2))
133
+ else:
134
+ print(f"{verdict_obj.verdict.value}: {verdict_obj.reason}")
135
+
136
+
137
+ def attach(
138
+ subparsers: Any,
139
+ *,
140
+ config_resolver: Callable[[argparse.Namespace], Any],
141
+ ) -> list[str]:
142
+ """Build a subcommand for every registered verb that carries a CLI adapter.
143
+
144
+ `subparsers` is the object returned by `parser.add_subparsers()`.
145
+ `config_resolver(args) -> SubstrateConfig` is injected by the consumer (cli.py
146
+ passes its own workspace-resolution), so this module never duplicates cli.py's
147
+ plumbing. Returns the list of verb names it wired (for tests / help). The
148
+ `cli.py` integration is one line: `verdict_cli.attach(sub, config_resolver=...)`.
149
+ """
150
+ _ensure_cli_specs()
151
+ wired: list[str] = []
152
+ for spec in verdicts.all_specs().values():
153
+ if spec.add_arguments is None or spec.gather is None:
154
+ continue # a library-only verb (no CLI surface) — skip, don't crash
155
+ p = subparsers.add_parser(spec.name, help=spec.summary)
156
+ spec.add_arguments(p)
157
+ p.add_argument("--workspace", default=".",
158
+ help="the workspace whose state the verb reads")
159
+ p.add_argument("--output", choices=["text", "json"], default="text")
160
+ p.set_defaults(_verdict_spec=spec, _config_resolver=config_resolver)
161
+ wired.append(spec.name)
162
+ return wired
163
+
164
+
165
+ def run(args: argparse.Namespace) -> int:
166
+ """The uniform handler for any verdict verb wired by `attach`.
167
+
168
+ gather (boundary I/O) → classify (pure) → render → exit code. One function for
169
+ ALL verbs — the dispatcher's payoff. Set as the subparser's func by `attach`
170
+ via `_verdict_spec`; a consumer with its own dispatch can call this directly.
171
+ """
172
+ spec: verdicts.VerdictSpec = args._verdict_spec
173
+ cfg = args._config_resolver(args)
174
+ evidence = spec.gather(args, cfg)
175
+ policy = spec.policy_from(cfg) if spec.policy_from is not None else _default_policy(spec)
176
+ verdict_obj = spec.classify(evidence, policy) if policy is not None else spec.classify(evidence)
177
+ _render(verdict_obj, getattr(args, "output", "text"))
178
+ return spec.exit_codes.get(verdict_obj.verdict.value, 0)
179
+
180
+
181
+ def _default_policy(spec: verdicts.VerdictSpec) -> Any:
182
+ """The verb's own default policy when the spec declares no `policy_from`.
183
+
184
+ Looked up by name so we don't couple to a specific module here. (The
185
+ `dos.toml [<name>]` policy seam is wired per-verb via `policy_from`; until a
186
+ verb declares it, its module default applies — e.g. `scope.DEFAULT_POLICY`.)"""
187
+ if spec.name == "scope":
188
+ return scope.DEFAULT_POLICY
189
+ return None # classify(evidence) with its own default argument
dos/verdict_journal.py ADDED
@@ -0,0 +1,497 @@
1
+ """Verdict-journal — a write-ahead log for the kernel's own adjudications (docs/262).
2
+
3
+ DOS adjudicates a firehose of verdicts every loop — `verify` (SHIPPED /
4
+ NOT_SHIPPED), `liveness` (ADVANCING / SPINNING / STALLED), `productivity`,
5
+ `efficiency`, `breaker`, `hook_exit`, `reward`, every hook decision — and until
6
+ this module **only one of them persisted**: `arbitrate`, and only because the lane
7
+ WAL (`lane_journal`) happened to need the lease set across processes. Every other
8
+ verdict was computed at the CLI boundary, printed, and evaporated. So "every
9
+ liveness verdict this run emitted," "when did efficiency cross into WASTEFUL," and
10
+ "what did this fleet decide last hour" were all unanswerable — the read-only
11
+ projections (`trace`/`decisions`/`top`) could only join the surfaces that
12
+ incidentally persisted.
13
+
14
+ This module is the missing substrate: the **same WAL discipline `lane_journal`
15
+ proves, re-aimed from leases onto verdicts** (the relationship `efficiency` has to
16
+ `productivity`, or `resume` to `liveness`). Every adjudication the kernel makes can
17
+ be appended — and `fsync`'d — to an append-only JSONL file as a structured,
18
+ `run_id`-correlated `VerdictEvent`; `rollup()` folds the log into per-dimension
19
+ verdict counts (pure: entries in, counts out, no disk), and `read_all`/`tail`
20
+ answer history queries. A new `dos observe` projection reads it.
21
+
22
+ Design rules (inherited verbatim from `lane_journal` — the LJ scope boundary):
23
+
24
+ * **Pure where it can be.** `rollup()` / `for_run()` take entries and return data
25
+ — entries in, value out, no disk — so the suite folds them without touching a
26
+ file. Only `record` / `read_all` / `tail` touch disk.
27
+ * **Fail-soft, never fail-loud.** Observability must never take down the thing it
28
+ observes: `record()` that cannot write logs-and-drops (the `notify.send_safely`
29
+ posture), it never raises into the syscall that emitted the verdict. A truth
30
+ syscall is not made less true by a full disk.
31
+ * **Torn-tail tolerant.** A process killed mid-`record` can leave a partial final
32
+ line. `read_all` skips an unparseable *trailing* line (and only the trailing
33
+ one); a non-trailing corrupt line is kept as a `_CORRUPT` sentinel so an audit
34
+ still sees the integrity breach.
35
+ * **Host-local + run-correlated.** Every event stamps the `run_id` spine key (or
36
+ `""` when the emitter had none — surfaced honestly, never guessed by a time
37
+ window). The join to `trace`/`decisions` is the existing `run_id`, nothing
38
+ fabricated (the `trace` non-goal: no second parallel correlation id).
39
+ * **The recorder is not a judge.** This module records verdicts other syscalls
40
+ *already minted* — it adds no precondition, runs no `classify`, mints no belief.
41
+ Delete it and you lose the record, not any adjudication. (The `trace` contract.)
42
+ * **Byte-clean by construction (docs/138).** A `VerdictEvent.detail` carries the
43
+ *environment-authored* evidence counts the verdict was computed from (tokens,
44
+ work units, ages — the same byte-clean inputs `efficiency`/`liveness` trust),
45
+ NEVER the agent's narration. The recorder is handed a typed verdict downstream of
46
+ the classify; it is structurally incapable of recording "the agent says it's
47
+ done."
48
+
49
+ Read::
50
+
51
+ dos observe # fleet-wide rollup over the whole journal
52
+ dos observe --run <run_id> # one run's verdict history
53
+ dos observe --syscall NAME # filter to one dimension
54
+ dos observe --tail N # the last N events, raw
55
+ dos observe --json # machine-readable
56
+
57
+ Write is library-only (a syscall verb / hook sensor calls `record()` as it emits)
58
+ — there is deliberately no `record` CLI subcommand, so nothing can journal a
59
+ verdict the kernel did not actually return.
60
+ """
61
+ from __future__ import annotations
62
+
63
+ import datetime as dt
64
+ import io
65
+ import json
66
+ import os
67
+ import sys
68
+ from dataclasses import dataclass, field
69
+ from pathlib import Path
70
+ from typing import Any, Iterable, Mapping
71
+
72
+ if hasattr(sys.stdout, "reconfigure"):
73
+ try:
74
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
75
+ except Exception: # pragma: no cover
76
+ pass
77
+ elif not isinstance(sys.stdout, io.TextIOWrapper): # pragma: no cover
78
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
79
+
80
+ from dos import config as _config
81
+
82
+ # The durable-schema family + version for verdict-journal records (docs/207). Every
83
+ # record is tagged from line 1 (unlike the lane journal, whose lease ops predate the
84
+ # tag contract) so a future non-additive shape change migrates cleanly. A new field
85
+ # is additive and never bumps it; the version bumps only on a breaking change.
86
+ SCHEMA_FAMILY = "verdict-journal"
87
+ VERDICT_JOURNAL_SCHEMA = 1
88
+
89
+ # The closed-ish set of syscall dimensions a verdict event names. "Closed-ish"
90
+ # because a host driver may emit its own verdict kind (a JUDGE rung verdict, a
91
+ # custom sensor) and the recorder must not refuse it — the set below is the kernel's
92
+ # OWN verdict-emitting syscalls, used for the `--syscall` filter's known-values hint
93
+ # and the rollup's stable ordering, NOT a validation gate. An unknown syscall is
94
+ # recorded as-is (the tolerant-floor posture: never drop a real event).
95
+ SYSCALL_VERIFY = "verify"
96
+ SYSCALL_LIVENESS = "liveness"
97
+ SYSCALL_PRODUCTIVITY = "productivity"
98
+ SYSCALL_EFFICIENCY = "efficiency"
99
+ SYSCALL_ARBITRATE = "arbitrate"
100
+ SYSCALL_REWARD = "reward"
101
+ SYSCALL_BREAKER = "breaker"
102
+ SYSCALL_HOOK_EXIT = "hook_exit"
103
+ SYSCALL_PRETOOL = "pretool"
104
+ SYSCALL_POSTTOOL = "posttool"
105
+ SYSCALL_STOP = "stop"
106
+
107
+ # Stable display/rollup order for the kernel's own dimensions; an unknown syscall
108
+ # sorts after these (alphabetically) so a custom emitter is visible, just last.
109
+ KNOWN_SYSCALLS = (
110
+ SYSCALL_VERIFY,
111
+ SYSCALL_LIVENESS,
112
+ SYSCALL_PRODUCTIVITY,
113
+ SYSCALL_EFFICIENCY,
114
+ SYSCALL_ARBITRATE,
115
+ SYSCALL_REWARD,
116
+ SYSCALL_BREAKER,
117
+ SYSCALL_HOOK_EXIT,
118
+ SYSCALL_PRETOOL,
119
+ SYSCALL_POSTTOOL,
120
+ SYSCALL_STOP,
121
+ )
122
+
123
+ # Who emitted the event — a kernel syscall verb, or a hook sensor. (A driver may
124
+ # pass its own source string; like `syscall`, this is descriptive, not validated.)
125
+ SOURCE_KERNEL = "kernel"
126
+ SOURCE_SENSOR = "sensor"
127
+
128
+
129
+ def journal_now_iso() -> str:
130
+ """Second-resolution UTC stamp for verdict events.
131
+
132
+ The same stamp grammar the lane journal uses (`lane_journal.journal_now_iso`):
133
+ fine enough to order events within a minute, with the monotonic `seq` as the
134
+ real tiebreak. Human-readable without ambiguity.
135
+ """
136
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # The record + the fold result — frozen value objects with to_dict()/from_dict()
141
+ # so the JSONL round-trips (mirrors lane_journal entries + decisions.Decision).
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class VerdictEvent:
147
+ """One adjudication the kernel made — a row in the verdict journal.
148
+
149
+ `syscall` is the dimension (`verify`/`liveness`/…); `verdict` is the typed token
150
+ that syscall returned (`SHIPPED`/`STALLED`/`WASTEFUL`…). `run_id` is the
151
+ correlation spine key (or "" when the emitter had none — surfaced honestly, the
152
+ docs/118 fail-toward-no-match rule). `subject` is an optional free identifier
153
+ (a `(plan,phase)`, a command, a step id); `lane` an optional region. `detail` is
154
+ a small dict of the ENVIRONMENT-authored evidence counts the verdict was
155
+ computed from (tokens, work, ages) — never the agent's narration (docs/138).
156
+ `source` names the emitter kind (`kernel`/`sensor`).
157
+ """
158
+
159
+ syscall: str
160
+ verdict: str
161
+ run_id: str = ""
162
+ lane: str = ""
163
+ subject: str = ""
164
+ detail: Mapping[str, Any] = field(default_factory=dict)
165
+ source: str = SOURCE_KERNEL
166
+ ts: str = ""
167
+ seq: int = 0
168
+
169
+ def to_record(self) -> dict:
170
+ """The JSONL record — schema-tagged, every field present.
171
+
172
+ The durable-schema tag rides at the top level (`schema_family`/
173
+ `schema_version`) so a reader can branch on shape without guessing.
174
+ """
175
+ return {
176
+ "schema_family": SCHEMA_FAMILY,
177
+ "schema_version": VERDICT_JOURNAL_SCHEMA,
178
+ "ts": self.ts or journal_now_iso(),
179
+ "seq": int(self.seq),
180
+ "syscall": self.syscall,
181
+ "verdict": self.verdict,
182
+ "run_id": self.run_id,
183
+ "lane": self.lane,
184
+ "subject": self.subject,
185
+ "detail": dict(self.detail) if isinstance(self.detail, Mapping) else {},
186
+ "source": self.source,
187
+ }
188
+
189
+ # `to_dict` is an alias kept for symmetry with `decisions.Decision.to_dict` /
190
+ # `trace`'s value objects, so a `--json` renderer can call the same method name
191
+ # across every projection.
192
+ to_dict = to_record
193
+
194
+ @classmethod
195
+ def from_record(cls, rec: Mapping[str, Any]) -> "VerdictEvent":
196
+ """Rebuild a `VerdictEvent` from a parsed JSONL record (tolerant).
197
+
198
+ Missing fields degrade to their defaults — a record written by an older
199
+ kernel (fewer fields) reads cleanly, the additive-schema floor. `detail` is
200
+ coerced to a dict (a malformed non-dict detail becomes {} rather than
201
+ raising — a reader never crashes on one bad row)."""
202
+ detail = rec.get("detail")
203
+ if not isinstance(detail, Mapping):
204
+ detail = {}
205
+ try:
206
+ seq = int(rec.get("seq") or 0)
207
+ except (TypeError, ValueError):
208
+ seq = 0
209
+ return cls(
210
+ syscall=str(rec.get("syscall") or ""),
211
+ verdict=str(rec.get("verdict") or ""),
212
+ run_id=str(rec.get("run_id") or ""),
213
+ lane=str(rec.get("lane") or ""),
214
+ subject=str(rec.get("subject") or ""),
215
+ detail=dict(detail),
216
+ source=str(rec.get("source") or SOURCE_KERNEL),
217
+ ts=str(rec.get("ts") or ""),
218
+ seq=seq,
219
+ )
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class VerdictRollup:
224
+ """The pure fold over a set of verdict events — counts per (dimension, token).
225
+
226
+ `by` names the dimension folded on (`syscall` by default; also `verdict`,
227
+ `run_id`, `lane`, `source`). `counts` maps each dimension value to a
228
+ {verdict-token: count} sub-map, so "47 liveness verdicts: 40 ADVANCING, 5
229
+ SPINNING, 2 STALLED" is one lookup. `total` is the event count; `corrupt` the
230
+ number of `_CORRUPT` sentinel lines seen (an integrity tally, surfaced not
231
+ hidden — the lane-journal posture). `dimensions` is the dimension values in
232
+ stable order (known syscalls first, then the rest alphabetically).
233
+ """
234
+
235
+ by: str
236
+ counts: Mapping[str, Mapping[str, int]]
237
+ dimensions: tuple[str, ...]
238
+ total: int
239
+ corrupt: int = 0
240
+
241
+ def to_dict(self) -> dict:
242
+ return {
243
+ "by": self.by,
244
+ "total": self.total,
245
+ "corrupt": self.corrupt,
246
+ "dimensions": list(self.dimensions),
247
+ "counts": {k: dict(v) for k, v in self.counts.items()},
248
+ }
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Path resolution — the active workspace's verdict journal, with an env override.
253
+ # Mirrors lane_journal._journal_path exactly.
254
+ # ---------------------------------------------------------------------------
255
+
256
+ # The workspace-neutral env override (parallel to DISPATCH_LANE_JOURNAL_PATH).
257
+ _ENV_PATH = "DISPATCH_VERDICT_JOURNAL_PATH"
258
+
259
+
260
+ def _default_journal_path() -> Path:
261
+ """The active workspace's verdict journal.
262
+
263
+ Falls back to a `verdict-journal.jsonl` sibling of the lane journal when the
264
+ config's `verdict_journal` field is unset (a `PathLayout` constructed
265
+ positionally without the defaulted field) — so a partially-built layout still
266
+ resolves to a sane sibling path rather than crashing on `None`."""
267
+ paths = _config.active().paths
268
+ vj = getattr(paths, "verdict_journal", None)
269
+ if vj is not None:
270
+ return Path(vj)
271
+ return Path(paths.lane_journal).with_name("verdict-journal.jsonl")
272
+
273
+
274
+ def _journal_path(path: Path | None = None) -> Path:
275
+ """Resolve the journal path: explicit arg › env override › active config.
276
+
277
+ Re-read each call so a test that sets the env var after import still redirects
278
+ (the lane-journal idiom)."""
279
+ if path is not None:
280
+ return Path(path)
281
+ env = os.environ.get(_ENV_PATH)
282
+ if env:
283
+ return Path(env)
284
+ return _default_journal_path()
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Write — append one event, fsync'd, FAIL-SOFT. The only mutating I/O.
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ def _next_seq(path: Path) -> int:
293
+ """The seq to stamp on the next event = max existing seq + 1 (1-based).
294
+
295
+ Best-effort: a read failure yields 1 (start fresh) rather than raising — the
296
+ seq is a within-file tiebreak, not a correctness invariant (the `ts` orders
297
+ across files; an O_APPEND write orders within one). Unlike the lane journal,
298
+ verdict events are not serialized under a lease mutex (they are emitted from
299
+ many independent syscalls), so two concurrent writers MAY mint the same seq —
300
+ tolerable, because the seq is cosmetic-ordering, never an identity. `read_all`
301
+ folds by append order and uses `ts`+`seq` only for display sort.
302
+ """
303
+ mx = 0
304
+ for e in read_all(path):
305
+ try:
306
+ s = int(e.get("seq") or 0)
307
+ except (TypeError, ValueError):
308
+ s = 0
309
+ if s > mx:
310
+ mx = s
311
+ return mx + 1
312
+
313
+
314
+ def record(event: VerdictEvent, *, path: Path | None = None,
315
+ stamp_seq: bool = True) -> bool:
316
+ """Append one `VerdictEvent` to the journal as JSONL, `fsync`'d. FAIL-SOFT.
317
+
318
+ Returns True on a successful durable append, False if the write failed (a full
319
+ disk, a permission error, a missing parent that could not be created) — the
320
+ caller's syscall is NEVER interrupted by an observability failure (the
321
+ `notify.send_safely` contract: the thing that observes must not crash the thing
322
+ observed). The dir is created on demand (`mkdir(parents=True)`), like the lease
323
+ writers. When `stamp_seq` (the default), an unset `seq`/`ts` is filled in here so
324
+ a caller can `record(VerdictEvent(syscall=…, verdict=…, run_id=…))` without
325
+ plumbing the clock/counter — the recorder owns the stamp the way `lane_lease`
326
+ owns the journal stamp.
327
+ """
328
+ try:
329
+ p = _journal_path(path)
330
+ p.parent.mkdir(parents=True, exist_ok=True)
331
+ rec = event.to_record()
332
+ if stamp_seq:
333
+ if not rec.get("ts"):
334
+ rec["ts"] = journal_now_iso()
335
+ if not rec.get("seq"):
336
+ rec["seq"] = _next_seq(p)
337
+ line = json.dumps(rec, ensure_ascii=False, sort_keys=True)
338
+ # O_APPEND keeps concurrent appends from interleaving a single line; fsync
339
+ # makes the record outlive the process that wrote it (the WAL invariant).
340
+ with open(p, "a", encoding="utf-8") as fh:
341
+ fh.write(line + "\n")
342
+ fh.flush()
343
+ try:
344
+ os.fsync(fh.fileno())
345
+ except (OSError, ValueError): # pragma: no cover - platform fsync quirks
346
+ pass
347
+ return True
348
+ except Exception:
349
+ # FAIL-SOFT: observability never takes down the observed. We deliberately
350
+ # swallow EVERY exception here (not a narrow set) — there is no failure mode
351
+ # of a verdict log worth crashing a truth syscall over.
352
+ return False
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Read — every event in append order, torn-tail tolerant. Mirrors
357
+ # lane_journal.read_all byte-for-byte in posture.
358
+ # ---------------------------------------------------------------------------
359
+
360
+
361
+ def read_all(path: Path | None = None) -> list[dict]:
362
+ """Return every journal record (raw dict) in append order.
363
+
364
+ Skips an unparseable TRAILING line (a torn final record from a crash
365
+ mid-append — "didn't happen," the safe WAL read) but keeps a non-trailing
366
+ corrupt line as a `{"op": "_CORRUPT", ...}` sentinel so an audit still sees the
367
+ integrity breach (the lane-journal reader, verbatim — same sentinel shape so a
368
+ shared audit can recognize it).
369
+ """
370
+ p = _journal_path(path)
371
+ if not p.exists():
372
+ return []
373
+ try:
374
+ raw = p.read_text(encoding="utf-8", errors="replace")
375
+ except OSError:
376
+ return []
377
+ lines = raw.splitlines()
378
+ out: list[dict] = []
379
+ for i, line in enumerate(lines):
380
+ s = line.strip()
381
+ if not s:
382
+ continue
383
+ try:
384
+ obj = json.loads(s)
385
+ except json.JSONDecodeError:
386
+ if i == len(lines) - 1:
387
+ break # torn final line — tolerated
388
+ out.append({"op": "_CORRUPT", "_raw": s, "_line": i})
389
+ continue
390
+ if isinstance(obj, dict):
391
+ out.append(obj)
392
+ return out
393
+
394
+
395
+ def tail(n: int = 20, path: Path | None = None) -> list[dict]:
396
+ """The last `n` records (raw dicts) — reads the whole file then slices.
397
+
398
+ The journal is NOT auto-rotated; on a long-lived fleet it grows unbounded and
399
+ this is O(file), the documented lane-journal posture (docs/262 Phase 4 folds
400
+ the `[retention]` caps over it). `n <= 0` returns all.
401
+ """
402
+ entries = read_all(path)
403
+ return entries[-n:] if n > 0 else entries
404
+
405
+
406
+ def read_events(path: Path | None = None) -> list[VerdictEvent]:
407
+ """`read_all`, decoded into `VerdictEvent`s, dropping `_CORRUPT` sentinels.
408
+
409
+ The typed reader the projections + folds consume. A corrupt sentinel is NOT a
410
+ verdict, so it is excluded here (its existence is still counted by `rollup` via
411
+ a separate pass over `read_all`, so the integrity tally survives)."""
412
+ return [
413
+ VerdictEvent.from_record(rec)
414
+ for rec in read_all(path)
415
+ if rec.get("op") != "_CORRUPT"
416
+ ]
417
+
418
+
419
+ # ---------------------------------------------------------------------------
420
+ # The pure folds — entries in, data out, no disk. The unit-test surface.
421
+ # ---------------------------------------------------------------------------
422
+
423
+
424
+ def _dimension_value(ev: VerdictEvent, by: str) -> str:
425
+ """The value of event `ev` along dimension `by` (defaults to syscall)."""
426
+ if by == "verdict":
427
+ return ev.verdict or "(none)"
428
+ if by == "run_id":
429
+ return ev.run_id or "(unattributed)"
430
+ if by == "lane":
431
+ return ev.lane or "(none)"
432
+ if by == "source":
433
+ return ev.source or "(none)"
434
+ return ev.syscall or "(none)" # "syscall" / default
435
+
436
+
437
+ def _order_dimensions(values: Iterable[str], by: str) -> tuple[str, ...]:
438
+ """Stable order: known syscalls first (when by==syscall), then the rest sorted.
439
+
440
+ For any other dimension the order is plain sorted (no privileged set). The
441
+ unattributed/none buckets sort to the end of the alphabetic tail naturally
442
+ because of the parenthesis prefix sort — acceptable; they are clearly labelled.
443
+ """
444
+ vals = set(values)
445
+ if by == "syscall":
446
+ head = [s for s in KNOWN_SYSCALLS if s in vals]
447
+ tail = sorted(v for v in vals if v not in set(KNOWN_SYSCALLS))
448
+ return tuple(head + tail)
449
+ return tuple(sorted(vals))
450
+
451
+
452
+ def rollup(events: Iterable[VerdictEvent], *, by: str = "syscall",
453
+ corrupt: int = 0) -> VerdictRollup:
454
+ """Fold events into per-`by` verdict counts. PURE — no disk.
455
+
456
+ For each event, increments `counts[dimension_value][verdict_token]`. `by` is one
457
+ of "syscall" (default) / "verdict" / "run_id" / "lane" / "source". `corrupt` is
458
+ the integrity tally the caller carries in from `read_all` (the count of
459
+ `_CORRUPT` sentinels) so the rollup can surface it without re-reading the file.
460
+
461
+ This is the lane-journal `replay` analogue: the pure reduction that turns the
462
+ raw event stream into the answer a projection renders. Unit-tested in isolation
463
+ (no file needed) exactly like `replay`.
464
+ """
465
+ counts: dict[str, dict[str, int]] = {}
466
+ total = 0
467
+ for ev in events:
468
+ total += 1
469
+ dim = _dimension_value(ev, by)
470
+ token = ev.verdict or "(none)"
471
+ bucket = counts.setdefault(dim, {})
472
+ bucket[token] = bucket.get(token, 0) + 1
473
+ dims = _order_dimensions(counts.keys(), by)
474
+ # Freeze the sub-maps in a stable order too (verdict tokens sorted) so the
475
+ # rendered + JSON output is deterministic.
476
+ frozen = {d: dict(sorted(counts[d].items())) for d in dims}
477
+ return VerdictRollup(by=by, counts=frozen, dimensions=dims,
478
+ total=total, corrupt=corrupt)
479
+
480
+
481
+ def for_run(events: Iterable[VerdictEvent], run_id: str) -> list[VerdictEvent]:
482
+ """The slice of events attributed to `run_id` — the `trace` join (docs/262 P3).
483
+
484
+ The join key is the existing `run_id` spine, nothing fabricated (the `trace`
485
+ non-goal). Events with no `run_id` are NEVER guessed onto a run by time (the
486
+ docs/118 fail-toward-no-match rule) — a `for_run` over a run that emitted no
487
+ correlated verdict honestly returns []. Preserves append order.
488
+ """
489
+ return [ev for ev in events if ev.run_id == run_id]
490
+
491
+
492
+ def count_corrupt(raw: Iterable[Mapping[str, Any]]) -> int:
493
+ """Count the `_CORRUPT` sentinel rows in a `read_all` result (integrity tally).
494
+
495
+ A tiny helper so a projection can do one `read_all`, hand the typed events to
496
+ `rollup`/`for_run` and the raw rows here, without re-reading the file."""
497
+ return sum(1 for r in raw if r.get("op") == "_CORRUPT")