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/rewind.py ADDED
@@ -0,0 +1,587 @@
1
+ """rewind — the conversation-rewind verdict: backjump to a minted checkpoint + a no-good note (docs/164 F1.5).
2
+
3
+ > **A failed fix attempt accretes corruption: each retry pastes its own dead end
4
+ > into the next prompt, and the loop walks back into the same hole. So rewinding a
5
+ > conversation is the distrust primitive pointed at the transcript itself — roll
6
+ > back to a checkpoint the KERNEL stamped (never a turn the agent claims it was
7
+ > at), excise the dead-end turns, and re-attach only un-forged bytes (the kernel's
8
+ > own typed verdict + the environment's own error excerpt). Subtract forged
9
+ > context; author nothing.**
10
+
11
+ This is `resume`'s CONVERSATION-axis sibling, field-for-field. `resume` rewinds
12
+ GIT state (HEAD → a re-entry SHA over a `run_id`-keyed intent ledger); `rewind`
13
+ rewinds the TRANSCRIPT (turns → a `(turn_index, transcript_digest)` checkpoint over
14
+ the SAME `OP_SUSPEND` anchor). They share one anchor — `intent_ledger.suspend_entry`
15
+ records both the git `resume_sha` and the conversation checkpoint — each axis
16
+ reading its own field (docs/164 §3, the "two rewind axes, one anchor" line):
17
+
18
+ resume.resume_plan (LedgerState, AncestryFacts, policy) -> ResumePlan
19
+ rewind.rewind_plan (turns, SuspendCheckpoint, FireVerdict, …) -> RewindPlan
20
+ ^ THIS module
21
+
22
+ All I/O — DIGESTING the transcript turns (hashing bytes), reading the clock,
23
+ decoding the SUSPEND record off `intent.jsonl` — happens in the CALLER (a thin
24
+ `rewind_evidence.py` reader, the `resume_evidence` analogue), exactly as `resume`'s
25
+ git reads happen outside `resume_plan`. `rewind_plan` makes no subprocess, file, or
26
+ clock call: the per-turn digests arrive as data on the `TurnRef`s, and the verdict
27
+ only COMPARES pre-computed digests and FOLDS pre-built tokens. That is what lets the
28
+ whole rewind LOGIC be replay-tested on frozen turn + checkpoint fixtures — no live
29
+ loop needed.
30
+
31
+ The three load-bearing properties (docs/164 §6 litmus):
32
+
33
+ * **Rewind is to a MINTED anchor.** `rewind_to_turn` is `checkpoint.turn_index`
34
+ ONLY when the turn at that index DIGESTS to `checkpoint.transcript_digest` — the
35
+ stamp the kernel actually minted at SUSPEND. A mismatch (or a missing checkpoint)
36
+ is `UNANCHORED`: the kernel rewinds to NOTHING rather than to a turn the agent
37
+ claims it was at. This is the floor framing (`evidence.believe_under_floor`)
38
+ lifted to the conversation axis: the digest's byte-author is the kernel's hash of
39
+ the turn at SUSPEND, NOT the judged agent, so the digest is the non-forgeable
40
+ rewind anchor and `UNANCHORED` is precisely a floor failure.
41
+ * **The no-good note carries verdict + env bytes, NEVER a generated critique.** The
42
+ note is built from EXACTLY two byte-classes and the type system makes a third
43
+ impossible — there is no free-form `str` parameter anywhere on the note-building
44
+ path. (a) closed `rewind_tokens.VerdictToken`s rendered via a registry-owned
45
+ template over structured fields the kernel computed; (b) an `EnvExcerpt` whose
46
+ `accountability` MUST be non-`AGENT_AUTHORED` (`OS_RECORDED` / `THIRD_PARTY`) to
47
+ ATTACH — an `AGENT_AUTHORED` excerpt is structurally filtered out (the floor's
48
+ safe-direction no-op). So the F0 Traceback (env-authored) attaches, but anything
49
+ the judged agent could have authored cannot — the note carries the F0 bytes the
50
+ same way `dos_react.py:201` carries the Traceback, never `response.content`.
51
+ * **The loop terminates on GROUND TRUTH.** `rewind_plan` fires `REWIND` only when
52
+ `fire` is `Resume.DIVERGED` or `Convergence.THRASHING`/`STARVED` (the
53
+ `should_surface` set) — all computed from git ancestry + the residual upstream,
54
+ never a self-reported "I'm close." A `fire` carrying `CONVERGING`/`RESUMABLE`
55
+ yields `NO_REWIND` (the loop continues). The cap is on the ground-truth verdict,
56
+ not the agent's account.
57
+
58
+ The belief/effect line (docs/164 §1, the docs/99 advisory floor): `rewind_plan`
59
+ MINTS a belief (the checkpoint is a valid rewind anchor; these turns are dead ends)
60
+ and PROPOSES an effect (the consumer truncates its own transcript to `rewind_to_turn`
61
+ and re-enters with the note). **The kernel never truncates the transcript** — "the
62
+ host owns the transcript" (docs/164 P1.5). The verdict returns a PLAN; enacting it
63
+ is the loop's.
64
+
65
+ Pure stdlib — no third-party imports, no I/O inside a verdict. Imports only sibling
66
+ kernel modules (`resume`, `completion`, `evidence`/`log_source`, `intent_ledger`)
67
+ plus the `rewind_tokens` seam-data leaf — the "no host, no I/O policy" litmus, not
68
+ "no sibling import" (CLAUDE.md). It is KERNEL, not a driver: state-in / plan-out,
69
+ names no host, and a no-good NOTE is a refusal — the one thing docs/82 says the
70
+ kernel may author.
71
+ """
72
+
73
+ from __future__ import annotations
74
+
75
+ import enum
76
+ import hashlib
77
+ from dataclasses import dataclass, field
78
+ from typing import Optional
79
+
80
+ from dos.completion import Convergence
81
+ from dos.intent_ledger import SuspendCheckpoint
82
+ from dos.log_source import Accountability
83
+ from dos.resume import Resume
84
+ from dos.rewind_tokens import (
85
+ BASE_REWIND_TOKENS,
86
+ RewindTokenRegistry,
87
+ VerdictToken,
88
+ )
89
+
90
+ __all__ = [
91
+ "Rewind",
92
+ "TurnRef",
93
+ "SuspendCheckpoint",
94
+ "FireVerdict",
95
+ "EnvExcerpt",
96
+ "NoGoodNote",
97
+ "RewindPolicy",
98
+ "DEFAULT_REWIND_POLICY",
99
+ "RewindPlan",
100
+ "digest_turn",
101
+ "build_no_good_note",
102
+ "rewind_plan",
103
+ ]
104
+
105
+
106
+ # ───────────────────────────── the typed rewind verdict ──────────────────────
107
+ class Rewind(str, enum.Enum):
108
+ """The typed rewind verdict — three states, mutually exclusive (docs/164 F1.5).
109
+
110
+ `str`-valued so it round-trips a `--json` token / exit-code map without a lookup
111
+ table (the `Resume` / `Completion` / `Liveness` idiom). Only `REWIND` authorises
112
+ the consumer to truncate-and-re-enter; the other two keep the transcript intact.
113
+
114
+ REWIND — fire on a ground-truth stop signal AND a minted, matching anchor:
115
+ truncate to `rewind_to_turn`, excise `dropped_turns`, re-enter with
116
+ the no-good note.
117
+ NO_REWIND — the fire verdict is NOT a ground-truth stop signal (CONVERGING /
118
+ RESUMABLE / …): the loop continues, the transcript is untouched.
119
+ UNANCHORED — there IS a fire, but the checkpoint does not digest to the turn it
120
+ names (or there is no checkpoint): refuse to rewind to a turn the
121
+ kernel did not stamp. The §6 "rewind to a minted anchor or refuse"
122
+ floor — the conversation-axis analogue of `resume.DIVERGED`'s
123
+ refuse-don't-overwrite.
124
+ """
125
+
126
+ REWIND = "REWIND"
127
+ NO_REWIND = "NO_REWIND"
128
+ UNANCHORED = "UNANCHORED"
129
+
130
+ def __str__(self) -> str: # pragma: no cover - trivial
131
+ return self.value
132
+
133
+ @property
134
+ def is_actionable(self) -> bool:
135
+ """True iff the consumer should truncate-and-re-enter (REWIND only)."""
136
+ return self is Rewind.REWIND
137
+
138
+
139
+ # ───────────────────────────── the inputs (frozen data) ──────────────────────
140
+ @dataclass(frozen=True)
141
+ class TurnRef:
142
+ """One transcript turn, AS DATA — an `(index, digest)` pair, NEVER its content.
143
+
144
+ The verdict sees only indices + per-turn digests, never turn CONTENT, so no agent
145
+ prose can leak into the decision (the same reason `arg_provenance` is byte-clean:
146
+ the judged agent did not author the IDENTITY of its own turns' digests — the
147
+ kernel hashed them at the boundary). The BYTES live at the boundary; this is the
148
+ `ProgressEvidence`/`AncestryFacts` shape — caller-gathered facts handed to a pure
149
+ verdict.
150
+
151
+ index — the turn's position in the transcript (0-based; the host's own turn
152
+ numbering). What `rewind_to_turn` / `dropped_turns` are expressed in.
153
+ digest — the kernel's hex digest of the turn's bytes (`digest_turn`, computed at
154
+ the boundary). Compared against `SuspendCheckpoint.transcript_digest`
155
+ to decide whether the named turn is the one the kernel actually stamped.
156
+ """
157
+
158
+ index: int
159
+ digest: str
160
+
161
+
162
+ # `SuspendCheckpoint` — the minted conversation rewind anchor — is defined ONCE in
163
+ # `intent_ledger` (beside the SUSPEND record it serializes onto, the sibling of the
164
+ # git-axis `suspend_resume_sha`) and imported above. It is the `(turn_index,
165
+ # transcript_digest)` the kernel STAMPED at `OP_SUSPEND`: the rewind is valid ONLY if
166
+ # the live turn at `turn_index` still digests to `transcript_digest` (else the agent
167
+ # or a successor rewrote history under the checkpoint — `UNANCHORED`). `present=False`
168
+ # (its `absent()`) is the honest zero — no checkpoint minted — and yields UNANCHORED.
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class FireVerdict:
173
+ """The trigger: an already-computed ground-truth stop signal, passed in (never re-derived).
174
+
175
+ A CLOSED union of the two ground-truth stop signals docs/164 §6 names — wrapped as
176
+ the already-computed enum so the rewind verdict NEVER re-derives them (the
177
+ `resume`/`completion` reuse-not-reimplement rule). The boundary computes them
178
+ (`resume.resume_plan` → `Resume`; `completion.convergence` → `Convergence`) and
179
+ hands the enum in.
180
+
181
+ resume_verdict — a `resume.Resume` value, or None if this fire is not a
182
+ resume signal. `DIVERGED` is the git-axis stop trigger.
183
+ convergence_verdict — a `completion.Convergence` value, or None. `THRASHING` /
184
+ `STARVED` (its `should_surface` set) are the loop-cap
185
+ triggers.
186
+
187
+ Exactly one of the two is expected to be set per fire (the boundary builds it from
188
+ whichever verdict fired); both-None is a non-firing verdict (`NO_REWIND`).
189
+ """
190
+
191
+ resume_verdict: Optional[Resume] = None
192
+ convergence_verdict: Optional[Convergence] = None
193
+
194
+ @classmethod
195
+ def from_resume(cls, verdict: Resume) -> "FireVerdict":
196
+ return cls(resume_verdict=verdict, convergence_verdict=None)
197
+
198
+ @classmethod
199
+ def from_convergence(cls, verdict: Convergence) -> "FireVerdict":
200
+ return cls(resume_verdict=None, convergence_verdict=verdict)
201
+
202
+ @property
203
+ def should_surface(self) -> bool:
204
+ """True iff this fire is a GROUND-TRUTH stop signal that warrants a rewind.
205
+
206
+ The cap is on the ground-truth verdict, never the agent's account (docs/164
207
+ §6): `Resume.DIVERGED` (ground truth moved past the resume point) OR a
208
+ `Convergence` in its `should_surface` set (THRASHING / STARVED — the residual
209
+ has no fixpoint). A `CONVERGING`/`RESUMABLE`/`COMPLETE`/`INSUFFICIENT` fire is
210
+ NOT a stop signal — the loop continues, the transcript is untouched.
211
+ """
212
+ if self.resume_verdict is Resume.DIVERGED:
213
+ return True
214
+ if self.convergence_verdict is not None and self.convergence_verdict.should_surface:
215
+ return True
216
+ return False
217
+
218
+ def _label(self) -> str:
219
+ """A short token for the operator reason (which signal fired)."""
220
+ if self.resume_verdict is not None:
221
+ return f"resume={self.resume_verdict.value}"
222
+ if self.convergence_verdict is not None:
223
+ return f"convergence={self.convergence_verdict.value}"
224
+ return "(no signal)"
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class EnvExcerpt:
229
+ """The (b)-class no-good byte: an environment error excerpt + its accountability rung.
230
+
231
+ The F0 re-surface bytes (`dos_react.py:201` — the Traceback, NOT
232
+ `response.content`). Wrapped so its `accountability` is carried: `build_no_good_note`
233
+ ATTACHES it ONLY when the rung is non-`AGENT_AUTHORED` (`OS_RECORDED` /
234
+ `THIRD_PARTY`), running the bytes through the `evidence.believe_under_floor`
235
+ framing — an `AGENT_AUTHORED` excerpt is structurally filtered out (the floor's
236
+ safe-direction no-op). So the env's own Traceback attaches; anything the judged
237
+ agent could have authored cannot.
238
+
239
+ text — the excerpt bytes (the Traceback / the env error). This IS a
240
+ `str`, but it is NOT a free-form caller slot for prose: it is
241
+ gated by `accountability`, so the only `str` that survives onto
242
+ the note is one a non-agent author wrote. A generated critique
243
+ tagged `AGENT_AUTHORED` is filtered; a generated critique tagged
244
+ `THIRD_PARTY` is a LIE about its byte-author the boundary reader
245
+ must not mint (the same discipline `log_source` enforces — the
246
+ tag is a ceiling fixed by the source, never inferred from
247
+ content).
248
+ accountability — who authored the bytes (`log_source.Accountability`). The
249
+ load-bearing field: the attach gate reads it.
250
+ """
251
+
252
+ text: str
253
+ accountability: Accountability
254
+
255
+ @property
256
+ def attaches(self) -> bool:
257
+ """True iff this excerpt's rung is non-forgeable, so it may attach to the note.
258
+
259
+ The floor discipline, restated for the env excerpt: an `AGENT_AUTHORED`
260
+ excerpt is structurally incapable of attaching (`believe_under_floor`'s
261
+ forgeable-floor no-op), so only the env's / a third party's own bytes survive.
262
+ """
263
+ return not self.accountability.is_agent_authored
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class NoGoodNote:
268
+ """The byte-clean re-entry annotation: closed verdict tokens + an env excerpt ONLY.
269
+
270
+ The docs/164 §6 no-good note. STRUCTURAL enforcement of "verdict + env bytes,
271
+ never a generated critique": this dataclass has NO constructor field of type
272
+ `str` that a caller fills freely. It holds `tuple[VerdictToken, ...]` (each a
273
+ closed `(kind, payload)` rendered via the registry's kernel-owned template) and an
274
+ optional `EnvExcerpt` (gated to a non-`AGENT_AUTHORED` rung). The closed
275
+ `VerdictToken` vocabulary + the registry-owned render template + the floor-gated
276
+ env passthrough together mean there is no reachable code path by which
277
+ model-generated prose becomes a note byte — the §6 grep-for-generated-prose litmus
278
+ has nothing to find.
279
+
280
+ tokens — the closed kernel verdict tokens (rendered by `render_lines`).
281
+ env_excerpt — the attached env error excerpt, or None when none crossed the
282
+ floor (a `None` excerpt is the absence of un-forged env bytes,
283
+ never a placeholder for prose).
284
+ registry — the `RewindTokenRegistry` whose templates render the tokens (the
285
+ active vocabulary; defaults to `BASE_REWIND_TOKENS`). Carried so
286
+ `render_lines` is self-contained — a token's rendered string is
287
+ always its OWN registry's kernel-authored template, never a
288
+ caller string.
289
+ """
290
+
291
+ tokens: tuple[VerdictToken, ...] = ()
292
+ env_excerpt: Optional[EnvExcerpt] = None
293
+ registry: RewindTokenRegistry = BASE_REWIND_TOKENS
294
+
295
+ def render_lines(self) -> tuple[str, ...]:
296
+ """The note's bytes, line by line — ONLY rendered tokens + the env excerpt.
297
+
298
+ Each token renders through the registry's kernel-owned template
299
+ (`registry.render`); the env excerpt (if any crossed the floor) is appended
300
+ verbatim under a kernel-authored header. There is no other source of bytes —
301
+ no caller `str`, no generated prose. This is the method a consumer prints when
302
+ re-entering the rewound conversation.
303
+ """
304
+ lines: list[str] = [self.registry.render(t) for t in self.tokens]
305
+ if self.env_excerpt is not None:
306
+ # The env excerpt already passed the floor at build time; we just echo its
307
+ # bytes under a kernel-authored header (the header is the kernel's, the body
308
+ # is the env's — both un-forged).
309
+ lines.append(f"env error excerpt [{self.env_excerpt.accountability.value}]:")
310
+ lines.append(self.env_excerpt.text)
311
+ return tuple(lines)
312
+
313
+ def to_dict(self) -> dict:
314
+ return {
315
+ "tokens": [{"kind": t.kind, "payload": dict(t.payload),
316
+ "rendered": self.registry.render(t)} for t in self.tokens],
317
+ "env_excerpt": (
318
+ {"text": self.env_excerpt.text,
319
+ "accountability": self.env_excerpt.accountability.value}
320
+ if self.env_excerpt is not None else None
321
+ ),
322
+ "lines": list(self.render_lines()),
323
+ }
324
+
325
+
326
+ @dataclass(frozen=True)
327
+ class RewindPolicy:
328
+ """The knobs that shape the rewind verdict — policy, not mechanism (the `ResumePolicy` split).
329
+
330
+ Defaults are GENERIC (no host tuning); a workspace could declare its own in
331
+ `dos.toml [rewind]` (a future seam, like the planned `[liveness]`/`[resume]`).
332
+
333
+ require_matching_digest — when True (the §6 default), the rewind target's live
334
+ turn MUST digest to `checkpoint.transcript_digest` or the verdict is
335
+ `UNANCHORED`. This is the non-forgeable-anchor floor; turning it off would let
336
+ the kernel rewind to a turn it never stamped (the bug the litmus forbids), so
337
+ it has no honest reason to be False — it exists as the explicit name of the
338
+ property a test pins, not as a knob a host should flip.
339
+ """
340
+
341
+ require_matching_digest: bool = True
342
+
343
+
344
+ DEFAULT_REWIND_POLICY = RewindPolicy()
345
+
346
+
347
+ @dataclass(frozen=True)
348
+ class RewindPlan:
349
+ """The single verdict `rewind_plan` returns, with the derivation echoed back.
350
+
351
+ `verdict` is the typed `Rewind`. `rewind_to_turn` is the minted anchor index
352
+ (only ever `checkpoint.turn_index`, and only on `REWIND`; `-1` otherwise).
353
+ `transcript_digest` is echoed so the consumer truncates to a kernel-stamped
354
+ anchor, never an agent-claimed turn. `dropped_turns` are the indices STRICTLY
355
+ AFTER `rewind_to_turn` (the dead-end turns to excise — pure subtraction).
356
+ `no_good_note` is the byte-clean annotation. `reason` is the operator one-liner
357
+ (kernel-authored — a refusal is legitimate kernel speech, docs/82). `to_dict` is
358
+ the `--json` shape (the `ResumePlan.to_dict` idiom).
359
+ """
360
+
361
+ verdict: Rewind
362
+ reason: str
363
+ rewind_to_turn: int = -1
364
+ transcript_digest: str = ""
365
+ dropped_turns: tuple[int, ...] = ()
366
+ no_good_note: NoGoodNote = field(default_factory=NoGoodNote)
367
+
368
+ @property
369
+ def is_actionable(self) -> bool:
370
+ return self.verdict.is_actionable
371
+
372
+ def to_dict(self) -> dict:
373
+ return {
374
+ "verdict": self.verdict.value,
375
+ "reason": self.reason,
376
+ "rewind_to_turn": self.rewind_to_turn,
377
+ "transcript_digest": self.transcript_digest,
378
+ "dropped_turns": list(self.dropped_turns),
379
+ "no_good_note": self.no_good_note.to_dict(),
380
+ }
381
+
382
+
383
+ # ───────────────────────────── the pure boundary helper ──────────────────────
384
+ def digest_turn(turn_bytes: "bytes | str") -> str:
385
+ """Hex digest of one turn's bytes — the kernel's hash, the rewind anchor's author.
386
+
387
+ A TINY pure helper (no I/O — it hashes bytes the caller already has in hand) the
388
+ boundary uses to build a `TurnRef.digest` and the SUSPEND checkpoint's
389
+ `transcript_digest`. Kept in the pure module (not the boundary reader) because it
390
+ is the DEFINITION of the anchor's identity: the digest's byte-author is THIS hash
391
+ of the turn, not the judged agent, which is exactly what makes the checkpoint
392
+ non-forgeable (the `evidence.believe_under_floor` framing, on the conversation
393
+ axis). `sha256`, hex, UTF-8 for a `str` input.
394
+ """
395
+ if isinstance(turn_bytes, str):
396
+ turn_bytes = turn_bytes.encode("utf-8")
397
+ return hashlib.sha256(turn_bytes).hexdigest()
398
+
399
+
400
+ def _digests_match(a: str, b: str) -> bool:
401
+ """True iff two hex digests match (case-insensitive, whitespace-tolerant, non-empty).
402
+
403
+ An empty digest on EITHER side never matches — a checkpoint with no digest, or a
404
+ turn the boundary couldn't hash, is not a valid anchor (fail-closed → UNANCHORED).
405
+ """
406
+ a = (a or "").strip().lower()
407
+ b = (b or "").strip().lower()
408
+ if not a or not b:
409
+ return False
410
+ return a == b
411
+
412
+
413
+ # ───────────────────────────── the no-good note builder ──────────────────────
414
+ def build_no_good_note(
415
+ tokens: "tuple[VerdictToken, ...] | list[VerdictToken]" = (),
416
+ env_excerpt: Optional[EnvExcerpt] = None,
417
+ *,
418
+ registry: RewindTokenRegistry = BASE_REWIND_TOKENS,
419
+ ) -> NoGoodNote:
420
+ """Assemble a `NoGoodNote` from ONLY (a) closed verdict tokens and (b) a floor-gated env excerpt.
421
+
422
+ The docs/164 §6 byte contract, enforced STRUCTURALLY:
423
+
424
+ * (a) Every token must be a `VerdictToken` whose `kind` is a member of
425
+ `registry` — an unknown kind is DROPPED here (it would be un-renderable, so it
426
+ carries no kernel-authored bytes anyway; dropping it is the safe no-op rather
427
+ than minting a note that would later raise). There is no free-form `str`
428
+ parameter for a token's text: the rendered string comes from the registry's
429
+ kernel-owned template over the token's structured payload.
430
+ * (b) The env excerpt ATTACHES iff its `accountability` is non-`AGENT_AUTHORED`
431
+ (`EnvExcerpt.attaches`) — the `evidence.believe_under_floor` framing. An
432
+ `AGENT_AUTHORED` excerpt (or None) is filtered to None: recorded-but-not-
433
+ attached, the floor's safe-direction no-op. So the F0 Traceback (env-authored)
434
+ attaches, but a generated critique the agent could have authored cannot.
435
+
436
+ There is NO `critique` / `advice` / `message` parameter anywhere on this path —
437
+ that absence IS the lock. PURE — no I/O; it only filters + folds pre-built values.
438
+ """
439
+ kept: list[VerdictToken] = []
440
+ for t in tokens:
441
+ if not isinstance(t, VerdictToken):
442
+ # A non-token (a bare string smuggled in, a duck-typed look-alike) is
443
+ # dropped — the note never reads a foreign object's bytes, so no
444
+ # fabricated prose can sneak through a wrong type (the `gather_evidence`
445
+ # fail-safe posture, restated for note assembly).
446
+ continue
447
+ if registry.is_known(t.kind):
448
+ kept.append(t)
449
+ # An unknown kind is dropped (un-renderable → no un-forged bytes to carry).
450
+
451
+ # The env excerpt crosses the floor ONLY on a non-forgeable rung. None / forgeable
452
+ # → not attached (recorded nowhere, the safe no-op). This is the one place an
453
+ # arbitrary `str` could enter the note — and it is gated by who AUTHORED the bytes,
454
+ # never by content, so a model-authored string tagged AGENT_AUTHORED is filtered.
455
+ attached = env_excerpt if (env_excerpt is not None and env_excerpt.attaches) else None
456
+
457
+ return NoGoodNote(tokens=tuple(kept), env_excerpt=attached, registry=registry)
458
+
459
+
460
+ # ───────────────────────────── the pure verdict ──────────────────────────────
461
+ def rewind_plan(
462
+ turns: "tuple[TurnRef, ...] | list[TurnRef]",
463
+ checkpoint: SuspendCheckpoint,
464
+ fire: FireVerdict,
465
+ *,
466
+ verdict_tokens: "tuple[VerdictToken, ...] | list[VerdictToken]" = (),
467
+ env_excerpt: Optional[EnvExcerpt] = None,
468
+ policy: RewindPolicy = DEFAULT_REWIND_POLICY,
469
+ registry: RewindTokenRegistry = BASE_REWIND_TOKENS,
470
+ ) -> RewindPlan:
471
+ """Compute the conversation-rewind verdict. PURE — no I/O (docs/164 F1.5).
472
+
473
+ The fold (docs/164 §6):
474
+
475
+ 1. **NO_REWIND first — the loop terminates on GROUND TRUTH.** If `fire` is not a
476
+ ground-truth stop signal (`fire.should_surface` is False — a `CONVERGING` /
477
+ `RESUMABLE` / `COMPLETE` / `INSUFFICIENT` fire), the loop continues: NO_REWIND,
478
+ the transcript untouched. The cap is on the ground-truth verdict, never the
479
+ agent's "I'm close" (the §6 loop-termination litmus). The note is empty here —
480
+ there is nothing dead-ended to annotate.
481
+
482
+ 2. **UNANCHORED — rewind to a MINTED anchor or refuse.** The fire IS a stop
483
+ signal, but the rewind target must be a checkpoint the KERNEL stamped. Refuse
484
+ (UNANCHORED) when:
485
+ * the checkpoint is absent (no SUSPEND minted one), OR
486
+ * `policy.require_matching_digest` and the live turn at
487
+ `checkpoint.turn_index` does NOT digest to `checkpoint.transcript_digest`
488
+ (the agent or a successor rewrote history under the checkpoint, or the
489
+ index names no live turn).
490
+ This is the floor framing (`believe_under_floor`) on the conversation axis:
491
+ the digest's byte-author is the kernel's hash at SUSPEND, not the agent, so a
492
+ mismatch is a floor failure. The kernel rewinds to NOTHING (`rewind_to_turn`
493
+ = -1) rather than to a turn the agent claims — the §6 "never rewind to a
494
+ STEP_CLAIMED SHA / an un-stamped turn" litmus, conversation-side. The no-good
495
+ note is still BUILT (the verdict + env bytes are valid evidence the operator
496
+ wants), but no truncation is proposed.
497
+
498
+ 3. **REWIND — a minted, matching anchor + a ground-truth stop.** Truncate to
499
+ `checkpoint.turn_index`, excise `dropped_turns` (the indices STRICTLY AFTER
500
+ the anchor — pure subtraction, removes context, adds none), and re-enter with
501
+ the no-good note (closed verdict tokens + the floor-gated env excerpt ONLY).
502
+
503
+ The verdict is ADVISORY (docs/99 / docs/164 P1.5): it MINTS the belief (this
504
+ checkpoint is a valid rewind anchor; these turns are dead ends) and PROPOSES the
505
+ truncation; the act of truncating the transcript is the consumer's — "the host
506
+ owns the transcript." The kernel never mutates it.
507
+ """
508
+ # The note is built the same way in every branch — from ONLY closed verdict tokens
509
+ # + a floor-gated env excerpt. Building it unconditionally keeps the byte contract
510
+ # in one place (there is no branch where a different note-builder could leak prose).
511
+ note = build_no_good_note(verdict_tokens, env_excerpt, registry=registry)
512
+
513
+ # 1. NO_REWIND — the fire is not a ground-truth stop signal. The loop continues.
514
+ if not fire.should_surface:
515
+ return RewindPlan(
516
+ verdict=Rewind.NO_REWIND,
517
+ reason=(
518
+ f"no rewind — the fire verdict ({fire._label()}) is not a ground-truth "
519
+ f"stop signal (DIVERGED / THRASHING / STARVED); the loop continues, the "
520
+ f"transcript is untouched"
521
+ ),
522
+ rewind_to_turn=-1,
523
+ transcript_digest="",
524
+ dropped_turns=(),
525
+ no_good_note=NoGoodNote(registry=registry), # nothing dead-ended to annotate
526
+ )
527
+
528
+ # 2. UNANCHORED — there IS a stop signal, but the anchor is not kernel-minted.
529
+ if not checkpoint.present:
530
+ return RewindPlan(
531
+ verdict=Rewind.UNANCHORED,
532
+ reason=(
533
+ f"unanchored — {fire._label()} fired, but no conversation checkpoint was "
534
+ f"minted at SUSPEND; refusing to rewind to a turn the kernel did not "
535
+ f"stamp (the §6 minted-anchor floor — surface the no-good, propose no "
536
+ f"truncation)"
537
+ ),
538
+ rewind_to_turn=-1,
539
+ transcript_digest="",
540
+ dropped_turns=(),
541
+ no_good_note=note,
542
+ )
543
+
544
+ # Find the live turn at the checkpoint's index (the boundary handed us its digest).
545
+ live = next((t for t in turns if t.index == checkpoint.turn_index), None)
546
+ digest_ok = (
547
+ live is not None
548
+ and _digests_match(live.digest, checkpoint.transcript_digest)
549
+ )
550
+ if policy.require_matching_digest and not digest_ok:
551
+ why = (
552
+ "no live turn at the checkpoint's index"
553
+ if live is None
554
+ else "the live turn's digest does not match the stamp the kernel minted"
555
+ )
556
+ return RewindPlan(
557
+ verdict=Rewind.UNANCHORED,
558
+ reason=(
559
+ f"unanchored — {fire._label()} fired, but {why} "
560
+ f"(checkpoint turn {checkpoint.turn_index}, digest "
561
+ f"{(checkpoint.transcript_digest or '∅')[:12]}); the agent or a successor "
562
+ f"rewrote history under the checkpoint. Refusing to rewind to a turn the "
563
+ f"kernel did not stamp (the §6 non-forgeable-anchor floor)"
564
+ ),
565
+ rewind_to_turn=-1,
566
+ transcript_digest="",
567
+ dropped_turns=(),
568
+ no_good_note=note,
569
+ )
570
+
571
+ # 3. REWIND — a minted, matching anchor + a ground-truth stop. Subtraction-only:
572
+ # drop every turn STRICTLY AFTER the anchor (removes context, adds none).
573
+ dropped = tuple(sorted(t.index for t in turns if t.index > checkpoint.turn_index))
574
+ return RewindPlan(
575
+ verdict=Rewind.REWIND,
576
+ reason=(
577
+ f"rewind to minted checkpoint turn {checkpoint.turn_index} (digest "
578
+ f"{checkpoint.transcript_digest[:12]}) — {fire._label()} is a ground-truth "
579
+ f"dead end; excising {len(dropped)} dead-end turn(s) and re-entering with a "
580
+ f"no-good note ({len(note.tokens)} verdict token(s)"
581
+ f"{', + env excerpt' if note.env_excerpt is not None else ''})"
582
+ ),
583
+ rewind_to_turn=checkpoint.turn_index,
584
+ transcript_digest=checkpoint.transcript_digest,
585
+ dropped_turns=dropped,
586
+ no_good_note=note,
587
+ )