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/completion.py ADDED
@@ -0,0 +1,466 @@
1
+ """completion — the live completion verdict: is the WHOLE job verifiably done? (docs/117).
2
+
3
+ The gap this closes
4
+ ===================
5
+
6
+ Every agentic loop today terminates on **budget**, not on **done**. The proof is
7
+ in the kernel's own stop vocabulary: `loop_decide.StopReason` enumerates eleven
8
+ ways a loop can stop and **not one means "the work is finished"** — every terminal
9
+ path is a give-up (`ITERATION_CAP`), a circuit-break (`CONSECUTIVE_*`), an outage
10
+ (`RATE_LIMITED`/`LAUNCH_FAILED`), or a stall (`SPINNING`). `ITERATION_CAP` *is* the
11
+ "pass": the loop stops because it ran its rounds, and a human later runs
12
+ `dos resume` and discovers it was resumable the whole time. The fixpoint test
13
+ ("is the residual empty?") already exists — it is just trapped in the
14
+ crash-recovery framing of `resume.py`, where it only runs when a run *died*.
15
+
16
+ This module lifts that fixpoint test out of the morgue and points it at a **live,
17
+ healthy** run: same `residual = declared − verified`, asked *forward* ("is it empty,
18
+ and may the loop stop?") instead of *backward* ("where do I re-enter?"). Completion
19
+ becomes the next distrust primitive — the verdict that refuses to take "✅ done" on
20
+ faith and adjudicates it against the fossils.
21
+
22
+ The self-report → distrust-verdict ladder (docs/117 §1.1), this is the missing rung:
23
+
24
+ "this step shipped" → verify() → SHIPPED / NOT_SHIPPED (oracle)
25
+ "I'm making progress" → liveness() → ADVANCING / SPINNING (liveness)
26
+ "I may take this region" → arbitrate() → ACQUIRE / refuse (arbiter)
27
+ "I crashed; resume from X" → resume_plan() → RESUMABLE / COMPLETE (resume)
28
+ "I'm done with the whole job"→ classify() → COMPLETE / INCOMPLETE ← THIS
29
+
30
+ Reuse, not reimplementation (docs/117 §5.1, §9)
31
+ ===============================================
32
+
33
+ `classify` does **not** re-derive the residual. It calls `resume.resume_plan` —
34
+ which already does the ancestry re-adjudication, the contiguous-prefix rule, and
35
+ the fail-closed treatment of a `STEP_CLAIMED`-but-unverified step (that step stays
36
+ IN the residual, `resume.py:282`) — and then **maps the backward verdict forward**:
37
+
38
+ resume.COMPLETE → Completion.COMPLETE (residual empty: stop-on-done)
39
+ resume.RESUMABLE → Completion.INCOMPLETE (residual non-empty: re-dispatch IT)
40
+ resume.DIVERGED → Completion.INCOMPLETE (work remains; ground truth moved —
41
+ still not done; carries the residual)
42
+ resume.UNRESUMABLE → Completion.INDETERMINATE (unsound fold / no intent: refuse to
43
+ CALL it done, don't guess — the floor)
44
+
45
+ So every property `resume` proved — claimed-≠-verified, contiguous-prefix coverage,
46
+ the `STEP_VERIFIED`-re-adjudicated-at-read fix (docs/107 §5 / docs/103) — is
47
+ inherited here for free. The only thing `completion` adds is the *forward framing*
48
+ and the *convergence* verdict over rounds (below); the residual arithmetic is
49
+ `resume`'s, byte-for-byte.
50
+
51
+ What is NOT here yet (the later phases of docs/117)
52
+ ===================================================
53
+
54
+ * **`UNDERDECLARED`** — the Gap-B refusal ("the residual is empty, but a
55
+ `ScopeSource` says the declared extent was smaller than the real job") is now
56
+ WIRED: `classify` takes `scope_verdicts` and folds them through
57
+ `scope_source.honest_under_floor` (docs/117 §5.3 / Phase 4) — the pluggable
58
+ extent rung, the `overlap_policy` shape, structurally able only to make
59
+ completion *harder*. With no verdicts supplied (the default) `classify` answers
60
+ from the declared steps alone — the honest floor, exactly as `resume` does — so
61
+ this is opt-in and byte-identical when unused. What is still future: a richer
62
+ set of *real* driver sources beyond the reference one, and the `dos complete
63
+ --scope-source` CLI / `dos.toml [completion] scope_sources` config seam that
64
+ populates `scope_verdicts` from a workspace declaration (today a caller passes
65
+ them explicitly; the kernel seam + one driver are the shipped part).
66
+ * **The loop-stop wiring** (`StopReason.COMPLETE`/`THRASHING`, residual
67
+ re-dispatch — docs/117 §5.4, Phase 3). This module ships the pure verdicts the
68
+ loop will read; it does not touch the running loop. Same staging as
69
+ `liveness` (the verdict shipped before the `loop_decide` consumer did).
70
+
71
+ Why a pure leaf with no I/O
72
+ ===========================
73
+
74
+ The `liveness`/`resume` rule: `classify(evidence, policy) -> verdict` makes no
75
+ subprocess/file/clock call — all evidence (`LedgerState`, `AncestryFacts`, the
76
+ residual-size history) is gathered at the caller boundary (the same git read
77
+ `resume`'s `dos resume` path does) and handed in, so the verdict is replay-tested
78
+ on frozen fixtures. The verdict is **advisory** (docs/99): it mints the belief "the
79
+ declared work is verifiably closed" / "this loop will not converge"; the act of
80
+ *stopping* is the loop's, never the kernel's.
81
+
82
+ Pure stdlib — no third-party imports, no I/O. Imports one sibling kernel module
83
+ (`resume`), exactly as `resume` imports `intent_ledger` — the "no host, no I/O
84
+ policy" litmus, not "no sibling import" (CLAUDE.md).
85
+ """
86
+
87
+ from __future__ import annotations
88
+
89
+ import enum
90
+ from dataclasses import dataclass
91
+ from typing import Optional
92
+
93
+ from dos.intent_ledger import LedgerState
94
+ from dos import resume as _resume
95
+ from dos.resume import AncestryFacts, ResumePolicy, DEFAULT_POLICY as _RESUME_DEFAULT_POLICY
96
+ from dos.scope_source import ScopeVerdict, honest_under_floor
97
+
98
+
99
+ # ───────────────────────────── the live completion verdict ────────────────────
100
+ class Completion(str, enum.Enum):
101
+ """The typed completion verdict — four states, mutually exclusive (docs/117 §5.1).
102
+
103
+ `str`-valued so it round-trips a `--json` token / exit-code map without a lookup
104
+ table (the `Resume` / `Liveness` / `gate_classify.Verdict` idiom). The asymmetry
105
+ is the point: only COMPLETE authorises the loop to stop-on-done; everything else
106
+ keeps the work open (INCOMPLETE re-dispatches; INDETERMINATE refuses to assert
107
+ done on an unsound fold).
108
+ """
109
+
110
+ COMPLETE = "COMPLETE" # residual empty — every declared step verified; the loop MAY stop-on-done
111
+ INCOMPLETE = "INCOMPLETE" # residual non-empty — verifiably more to do; re-dispatch the residual
112
+ UNDERDECLARED = "UNDERDECLARED" # residual empty BUT an external ScopeSource says the extent under-declared (Phase 4; not emitted yet)
113
+ INDETERMINATE = "INDETERMINATE" # unsound fold / no intent — refuse to CALL it done, don't guess (the floor)
114
+
115
+ def __str__(self) -> str: # pragma: no cover - trivial
116
+ return self.value
117
+
118
+ @property
119
+ def is_done(self) -> bool:
120
+ """True iff the loop is authorised to stop because the work is finished."""
121
+ return self is Completion.COMPLETE
122
+
123
+ @property
124
+ def has_residual(self) -> bool:
125
+ """True iff there is verifiably more declared work to do (INCOMPLETE only)."""
126
+ return self is Completion.INCOMPLETE
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class CompletionVerdict:
131
+ """The single verdict `classify` returns, with the derivation echoed back.
132
+
133
+ `state` is the typed `Completion`. `reason` is the operator-facing one-liner.
134
+ `residual` is the ordered remaining step ids (empty iff COMPLETE) — the loop
135
+ re-dispatches THESE, not a fresh pass (docs/117 §5.4). `verified` is the
136
+ contiguous-verified prefix the COMPLETE/INCOMPLETE rests on. `declared` is the
137
+ full declared extent (so a reader sees the denominator). `run_id` keys it.
138
+ `to_dict` is the `--json` shape (the `ResumePlan.to_dict` idiom).
139
+ """
140
+
141
+ state: Completion
142
+ reason: str
143
+ run_id: str
144
+ residual: tuple[str, ...] = ()
145
+ verified: tuple[str, ...] = ()
146
+ declared: tuple[str, ...] = ()
147
+
148
+ @property
149
+ def fraction_done(self) -> Optional[float]:
150
+ """|verified| / |declared| — the closure fraction, or None when nothing is
151
+ declared (a free-form goal has no step denominator). A legibility aid for the
152
+ surfaced line; never load-bearing for the verdict itself."""
153
+ n = len(self.declared)
154
+ return (len(self.verified) / n) if n else None
155
+
156
+ def to_dict(self) -> dict:
157
+ out = {
158
+ "state": self.state.value,
159
+ "reason": self.reason,
160
+ "run_id": self.run_id,
161
+ "residual": list(self.residual),
162
+ "verified": list(self.verified),
163
+ "declared": list(self.declared),
164
+ "is_done": self.state.is_done,
165
+ }
166
+ frac = self.fraction_done
167
+ if frac is not None:
168
+ out["fraction_done"] = round(frac, 4)
169
+ return out
170
+
171
+
172
+ def classify(
173
+ state: LedgerState,
174
+ ancestry: AncestryFacts,
175
+ policy: ResumePolicy = _RESUME_DEFAULT_POLICY,
176
+ scope_verdicts: tuple[ScopeVerdict, ...] = (),
177
+ ) -> CompletionVerdict:
178
+ """Adjudicate whether the WHOLE declared job is verifiably done. PURE — no I/O.
179
+
180
+ Reuses `resume.resume_plan`'s residual arithmetic verbatim (docs/117 §5.1) and
181
+ maps its backward (where-do-I-re-enter) verdict to a forward (may-I-stop) one:
182
+
183
+ * `resume.COMPLETE` → `COMPLETE` — residual empty; every declared step
184
+ verified on the non-forgeable rung.
185
+ * `resume.RESUMABLE` → `INCOMPLETE` — a non-empty residual remains; the loop
186
+ re-dispatches it (carried on `.residual`).
187
+ * `resume.DIVERGED` → `INCOMPLETE` — work remains AND ground truth moved past
188
+ the resume point. Still not done; the
189
+ residual is carried so the loop/operator
190
+ can reconcile (the divergence is in the
191
+ reason, but the completion answer is the
192
+ same "no, not done").
193
+ * `resume.UNRESUMABLE` → `INDETERMINATE`— no INTENT, a corrupt fold, or a schema
194
+ this kernel is too old to read: refuse to
195
+ CALL it done (the floor — never assert
196
+ completion on an unsound fold).
197
+
198
+ The verdict is **advisory** (docs/99): it mints "done / not done / can't tell" and
199
+ the loop *decides* to stop on COMPLETE; the kernel never re-runs the work (docs/117
200
+ §8).
201
+
202
+ The `scope` rung (docs/117 Phase 4) distrusts the residual's DENOMINATOR. When
203
+ `resume` says the residual is empty, `classify` does not grant `COMPLETE`
204
+ unconditionally — it first folds the caller-supplied `scope_verdicts` through
205
+ `scope_source.honest_under_floor`: `COMPLETE` requires the residual empty AND
206
+ every scope source agreeing the declared extent was the whole job. If any source
207
+ voted the extent under-declared, `classify` emits `UNDERDECLARED` instead (the
208
+ residual is empty, but the *scope* the residual was measured against was too
209
+ small — `docs/103` inward, on the denominator). With no `scope_verdicts` (the
210
+ default `()`), `honest_under_floor(())` is honest, so completion is **exactly
211
+ today's "all declared verified" floor** and `UNDERDECLARED` is never emitted — the
212
+ Phase-1 behavior, byte-for-byte. The sources are gathered + run (`run_scope`,
213
+ fail-to-strict) at the caller boundary and handed in, exactly as `AncestryFacts`
214
+ is — the verdict stays pure and replay-testable.
215
+ """
216
+ plan = _resume.resume_plan(state, ancestry, policy)
217
+ declared = tuple(state.declared_steps)
218
+ rid = plan.run_id
219
+
220
+ if plan.verdict is _resume.Resume.COMPLETE:
221
+ # The residual is empty. Before calling it DONE, distrust the denominator:
222
+ # fold the scope verdicts. With no sources wired this is honest (today's
223
+ # floor); any source flagging under-declaration flips it to UNDERDECLARED.
224
+ scope = honest_under_floor(tuple(scope_verdicts))
225
+ n = len(plan.verified) or len(declared)
226
+ if not scope.extent_honest:
227
+ return CompletionVerdict(
228
+ state=Completion.UNDERDECLARED,
229
+ reason=(
230
+ f"all {n} declared unit(s) verified, BUT the declared extent is "
231
+ f"not the whole job — {scope.reason}; not done (a human must "
232
+ f"reconcile the scope before it can close)"
233
+ ),
234
+ run_id=rid,
235
+ residual=(),
236
+ verified=plan.verified,
237
+ declared=declared,
238
+ )
239
+ return CompletionVerdict(
240
+ state=Completion.COMPLETE,
241
+ reason=(
242
+ f"all {n} declared unit(s) verified against ancestry — the residual is "
243
+ f"empty; the declared job is done (stop-on-done, not out-of-budget)"
244
+ ),
245
+ run_id=rid,
246
+ residual=(),
247
+ verified=plan.verified,
248
+ declared=declared,
249
+ )
250
+
251
+ if plan.verdict is _resume.Resume.UNRESUMABLE:
252
+ # The fold is unsound (no INTENT / corrupt / too-new schema). We cannot
253
+ # ground a residual, so we cannot soundly say "done" OR "this much remains".
254
+ # Refuse to assert completion — the `resume.UNRESUMABLE` floor, restated.
255
+ return CompletionVerdict(
256
+ state=Completion.INDETERMINATE,
257
+ reason=(
258
+ f"cannot adjudicate completion — {plan.reason} "
259
+ f"(refusing to call a job done from an unsound ledger fold)"
260
+ ),
261
+ run_id=rid,
262
+ residual=plan.residual,
263
+ verified=plan.verified,
264
+ declared=declared,
265
+ )
266
+
267
+ # RESUMABLE or DIVERGED — both mean "verifiably more to do". The completion
268
+ # answer is the same INCOMPLETE; the residual is carried so the loop re-dispatches
269
+ # exactly the unfinished units (docs/117 §5.4 step 3), and the reason preserves
270
+ # the divergence note when resume flagged it (so the operator still sees it).
271
+ n_resid = len(plan.residual)
272
+ n_decl = len(declared) or n_resid
273
+ if plan.verdict is _resume.Resume.DIVERGED:
274
+ reason = (
275
+ f"INCOMPLETE — {n_resid} of {n_decl} declared unit(s) unverified, AND "
276
+ f"ground truth advanced past the resume point ({plan.reason}); not done — "
277
+ f"the residual must be reconciled before it can close"
278
+ )
279
+ else:
280
+ reason = (
281
+ f"INCOMPLETE — {len(plan.verified)}/{n_decl} declared unit(s) verified; "
282
+ f"{n_resid} remain in the residual ({plan.reason})"
283
+ )
284
+ return CompletionVerdict(
285
+ state=Completion.INCOMPLETE,
286
+ reason=reason,
287
+ run_id=rid,
288
+ residual=plan.residual,
289
+ verified=plan.verified,
290
+ declared=declared,
291
+ )
292
+
293
+
294
+ # ───────────────────────────── the convergence verdict ────────────────────────
295
+ # docs/117 §5.2 / Gap C. COMPLETE is a STATIC fixpoint (residual empty *now*). The
296
+ # "can't stop" failure is DYNAMIC: the residual never empties because each round adds
297
+ # as much as it closes (the reviewer-finds-new-findings loop). This verdict is over a
298
+ # HISTORY of residual sizes — one int per completed round — and answers "is |residual|
299
+ # actually shrinking, or is the loop busy-but-forever?".
300
+
301
+
302
+ class Convergence(str, enum.Enum):
303
+ """Is the residual trending to empty, or oscillating/growing forever? (docs/117 §5.2).
304
+
305
+ A DIFFERENT "no" from the two we already have:
306
+ * `liveness.SPINNING` = not committing at all (zero forward git delta) — temporal.
307
+ * `resume.RESUMABLE` = work remains (residual non-empty) — a single snapshot.
308
+ * `THRASHING` (here) = commits ARE landing, the residual IS changing, but it is
309
+ not monotonically decreasing — the loop is productive and
310
+ will run forever. The honest verdict for "no fixpoint".
311
+ """
312
+
313
+ CONVERGING = "CONVERGING" # |residual| (weakly) decreasing toward 0 — keep going
314
+ THRASHING = "THRASHING" # |residual| failed to decrease for max_nonconverging rounds — surface, don't burn budget
315
+ STARVED = "STARVED" # |residual| non-empty and UNCHANGED across the window — distinct from THRASHING's churn
316
+ INSUFFICIENT = "INSUFFICIENT" # too few rounds to judge a trend yet — keep going (no verdict)
317
+
318
+ def __str__(self) -> str: # pragma: no cover - trivial
319
+ return self.value
320
+
321
+ @property
322
+ def should_surface(self) -> bool:
323
+ """True iff a loop should STOP-and-surface rather than continue (the no-fixpoint set)."""
324
+ return self in (Convergence.THRASHING, Convergence.STARVED)
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class ConvergencePolicy:
329
+ """Knobs for the convergence verdict — policy, not mechanism (the `ResumePolicy` split).
330
+
331
+ * ``max_nonconverging`` — how many consecutive rounds |residual| may fail to
332
+ strictly decrease before THRASHING. Default 3 — the existing circuit-breaker
333
+ idiom (`loop_decide`'s `max_unclear` / `max_dirty_zero`).
334
+ * ``window`` — how many of the most-recent rounds the trend is judged over.
335
+ Default 4. Fewer than 2 rounds is always INSUFFICIENT (no trend to read).
336
+
337
+ Defaults are GENERIC (no host tuning); a workspace could declare its own in
338
+ `dos.toml [completion]` (a future seam, like the planned `[liveness]`/`[resume]`).
339
+ """
340
+
341
+ max_nonconverging: int = 3
342
+ window: int = 4
343
+
344
+
345
+ DEFAULT_CONVERGENCE_POLICY = ConvergencePolicy()
346
+
347
+
348
+ @dataclass(frozen=True)
349
+ class ConvergenceVerdict:
350
+ """The typed convergence verdict + the derivation (the window it judged)."""
351
+
352
+ state: Convergence
353
+ reason: str
354
+ window: tuple[int, ...] = () # the residual sizes the verdict was read over (most recent last)
355
+
356
+ def to_dict(self) -> dict:
357
+ return {
358
+ "state": self.state.value,
359
+ "reason": self.reason,
360
+ "window": list(self.window),
361
+ "should_surface": self.state.should_surface,
362
+ }
363
+
364
+
365
+ def convergence(
366
+ residual_history: tuple[int, ...],
367
+ policy: ConvergencePolicy = DEFAULT_CONVERGENCE_POLICY,
368
+ ) -> ConvergenceVerdict:
369
+ """Read the residual-size trend across rounds. PURE — over a history of ints.
370
+
371
+ One int per completed round (the loop appends ``|residual|`` each iteration; the
372
+ history is cheap and lives in `LoopState`). The verdict (docs/117 §5.2):
373
+
374
+ * `CONVERGING` — within the window, |residual| is weakly decreasing and the
375
+ latest is below the window's first (it is trending to 0). Keep going.
376
+ * `STARVED` — the window is non-empty, > 0, and FLAT (every value equal):
377
+ no progress at all, distinct from THRASHING's churn.
378
+ * `THRASHING` — |residual| failed to STRICTLY decrease for the last
379
+ ``max_nonconverging`` rounds (it oscillated or grew): a productive loop with
380
+ no fixpoint — surface a decision, don't burn the cap silently.
381
+ * `INSUFFICIENT` — fewer than 2 rounds (or fewer than 2 in the window): no
382
+ trend to read yet; the loop continues (this is never a stop signal).
383
+
384
+ A residual that reaches 0 is CONVERGING (it converged) regardless of the path —
385
+ the static `COMPLETE` from `classify` is the authority on done-ness; this verdict
386
+ only catches the *won't-ever-get-there* case.
387
+ """
388
+ hist = tuple(int(x) for x in residual_history)
389
+ if len(hist) < 2:
390
+ return ConvergenceVerdict(
391
+ state=Convergence.INSUFFICIENT,
392
+ reason=(f"only {len(hist)} round(s) recorded — need ≥2 to read a trend; "
393
+ f"continue (no convergence verdict yet)"),
394
+ window=hist,
395
+ )
396
+
397
+ w = hist[-policy.window:] if policy.window > 0 else hist
398
+ first, last = w[0], w[-1]
399
+
400
+ # Converged (or converging to) empty — the happy path. A 0 anywhere recent means
401
+ # the static COMPLETE verdict will fire; never call that THRASHING.
402
+ if last == 0:
403
+ return ConvergenceVerdict(
404
+ state=Convergence.CONVERGING,
405
+ reason=f"residual reached 0 over {w} — converged",
406
+ window=w,
407
+ )
408
+
409
+ # Flat and non-empty across the whole window → STARVED (no churn, no progress).
410
+ if len(set(w)) == 1:
411
+ return ConvergenceVerdict(
412
+ state=Convergence.STARVED,
413
+ reason=(f"residual is unchanged at {last} across {len(w)} round(s) {w} — "
414
+ f"no progress; a precondition is likely blocking (surface)"),
415
+ window=w,
416
+ )
417
+
418
+ # THRASHING test — the residual CHURNS UPWARD without reaching a new low.
419
+ #
420
+ # The defining feature of a no-fixpoint loop is that the residual *bounces back
421
+ # up*: each pass closes some work and opens as much (the reviewer-finds-new-
422
+ # findings loop). The honest signal is therefore (a) an UP-step happened in the
423
+ # recent window — the residual grew at least once — AND (b) the latest value is
424
+ # NOT a new low for that window (it didn't end by breaking through its prior
425
+ # floor). Together: it went up and didn't recover, so it is going nowhere.
426
+ #
427
+ # This is the criterion a per-transition or endpoint test both get wrong:
428
+ # (4,3,4,3) — up-step 3→4 present, last 3 == window min 3 (not a NEW low) → THRASHING
429
+ # (1,2,3,4) — up-steps present, last 4 is the max (not a low) → THRASHING
430
+ # (8,5,3,1) — no up-step at all → CONVERGING
431
+ # We require k+1 rounds of history before trusting it, so one stray uptick inside
432
+ # an otherwise-improving run does not trip a stop (the decision must be confident).
433
+ k = policy.max_nonconverging
434
+ recent = hist[-(k + 1):]
435
+ if len(recent) >= k + 1:
436
+ went_up = any(recent[i + 1] > recent[i] for i in range(len(recent) - 1))
437
+ earlier_min = min(recent[:-1])
438
+ no_new_low = last >= earlier_min
439
+ if went_up and no_new_low:
440
+ return ConvergenceVerdict(
441
+ state=Convergence.THRASHING,
442
+ reason=(f"residual churned without reaching a new low over {k} round(s) "
443
+ f"{recent} (latest {last} ≥ window floor {earlier_min}) — the "
444
+ f"loop is productive but has no fixpoint; cut scope or accept "
445
+ f"partial (surface, don't burn the cap)"),
446
+ window=w,
447
+ )
448
+
449
+ # Net-decreasing across the window (below where it began) → CONVERGING.
450
+ if last < first:
451
+ return ConvergenceVerdict(
452
+ state=Convergence.CONVERGING,
453
+ reason=f"residual decreasing across {w} ({first} → {last}) — fixpoint reachable",
454
+ window=w,
455
+ )
456
+
457
+ # Stuck-but-young: not net-decreasing, but fewer than k+1 rounds of history — not
458
+ # confident enough to call THRASHING. Continue; the loop confirms or clears the
459
+ # trend as more rounds land. (CONVERGING here means "no stop signal yet," not
460
+ # "provably shrinking" — the reason says so.)
461
+ return ConvergenceVerdict(
462
+ state=Convergence.CONVERGING,
463
+ reason=(f"residual {w}: not yet net-decreasing but under the "
464
+ f"{policy.max_nonconverging}-round non-progress threshold — continue"),
465
+ window=w,
466
+ )
@@ -0,0 +1,154 @@
1
+ """Concurrency-class budgets as declared data — the operator surface over the
2
+ already-shipped arbiter class-budget enforcement (docs/97 Phase 1-2, C13).
3
+
4
+ The arbiter ALREADY enforces "at most N of kind K may hold a lease at once":
5
+ `arbiter.arbitrate(..., class_budgets={"priority": 3})` counts live leases per
6
+ kind on the auto-pick walk, skips budget-exhausted candidates, and returns the
7
+ named `CLASS_BUDGET_EXHAUSTED` refuse (`arbiter.py:356,366,714`). What was missing
8
+ is the *operator surface* — the budgets were reachable only as a Python parameter.
9
+ This module is that surface's data half: a closed `ConcurrencyClass{name,
10
+ max_concurrent}` dataclass + a `from_table` reader for the `[[concurrency_class]]`
11
+ array-of-tables in `dos.toml`, projecting to the exact `{kind: N}` dict the arbiter
12
+ consumes.
13
+
14
+ This is mechanism-as-data, the `reasons`/`stamp`/`lanes` seam pattern: the kernel
15
+ ships the enforcement; the host declares the VALUES per workspace. It names no host
16
+ class — `"priority"`, `"apply"`, whatever — those are workspace data, so Law 1
17
+ (kernel imports no host) holds. It deliberately carries ONLY a max-concurrent
18
+ budget; it does NOT carry lane priority/value ordering — the arbiter refuses to
19
+ hard-code "whose work is valuable" (docs/90 §6), so that stays host policy and
20
+ never enters this registry.
21
+
22
+ [[concurrency_class]]
23
+ name = "priority"
24
+ max_concurrent = 3
25
+
26
+ [[concurrency_class]]
27
+ name = "apply"
28
+ max_concurrent = 1
29
+
30
+ Pure stdlib leaf — the closed-enum-as-data discipline, validated loud-on-malformed
31
+ (a host that mis-declared a budget wants it surfaced at load, not silently dropped
32
+ to "no budget" which would let the class run unbounded).
33
+ """
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import dataclass
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ConcurrencyClass:
41
+ """One declared budget: at most `max_concurrent` leases of kind `name` at once.
42
+
43
+ `name` is the lane-KIND the arbiter keys budgets on (`lease["lane_kind"]`),
44
+ opaque workspace data. `max_concurrent` is a non-negative int — 0 means "admit
45
+ none of this kind" (a valid, if drastic, throttle); a negative value is a
46
+ declaration error.
47
+ """
48
+
49
+ name: str
50
+ max_concurrent: int
51
+
52
+ def __post_init__(self) -> None:
53
+ if not self.name:
54
+ raise ValueError("concurrency_class.name is required (the lane kind)")
55
+ if not isinstance(self.max_concurrent, int) or isinstance(self.max_concurrent, bool):
56
+ raise ValueError(
57
+ f"concurrency_class[{self.name!r}].max_concurrent must be an int, "
58
+ f"got {type(self.max_concurrent).__name__}"
59
+ )
60
+ if self.max_concurrent < 0:
61
+ raise ValueError(
62
+ f"concurrency_class[{self.name!r}].max_concurrent must be ≥ 0, "
63
+ f"got {self.max_concurrent}"
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ClassBudgets:
69
+ """The declared concurrency-class registry — an ordered set of `ConcurrencyClass`.
70
+
71
+ Carries the budgets as data and projects them to the `{kind: max_concurrent}`
72
+ dict `arbiter.arbitrate(class_budgets=...)` already consumes. Empty by default
73
+ (no file / no `[[concurrency_class]]` table → no budgets → today's unbounded-
74
+ per-kind behavior, the additive-degradation floor)."""
75
+
76
+ classes: tuple[ConcurrencyClass, ...] = ()
77
+
78
+ def as_arbiter_budgets(self) -> dict[str, int]:
79
+ """The `{kind: max_concurrent}` dict the arbiter takes. A duplicate name is a
80
+ last-wins override (the host declared the same class twice — honor the last,
81
+ the toml array's natural order)."""
82
+ out: dict[str, int] = {}
83
+ for c in self.classes:
84
+ out[c.name] = c.max_concurrent
85
+ return out
86
+
87
+ @classmethod
88
+ def from_table(cls, table: object) -> "ClassBudgets":
89
+ """Build from a parsed `[[concurrency_class]]` array-of-tables.
90
+
91
+ TOML's `[[concurrency_class]]` parses to a LIST of dicts. Tolerant of an
92
+ absent/empty list (→ no budgets). Rejects, with a `ValueError` naming the
93
+ offending entry, anything that is not a `{name, max_concurrent}` table —
94
+ loud-on-malformed, the sibling-seam discipline. Mirrors
95
+ `reason_morphology.MorphologyRuleset.from_table` in shape (the array-of-
96
+ tables reader)."""
97
+ if table is None:
98
+ return cls(())
99
+ if not isinstance(table, (list, tuple)):
100
+ raise ValueError(
101
+ f"[[concurrency_class]] must be an array of tables, "
102
+ f"got {type(table).__name__}"
103
+ )
104
+ out: list[ConcurrencyClass] = []
105
+ for i, item in enumerate(table):
106
+ if not isinstance(item, dict):
107
+ raise ValueError(
108
+ f"[[concurrency_class]] entry {i} must be a table "
109
+ f"({{name, max_concurrent}}), got {type(item).__name__}"
110
+ )
111
+ if "name" not in item or "max_concurrent" not in item:
112
+ raise ValueError(
113
+ f"[[concurrency_class]] entry {i} needs both `name` and "
114
+ f"`max_concurrent` (got keys {sorted(item)})"
115
+ )
116
+ # ConcurrencyClass.__post_init__ validates the value shapes (name
117
+ # non-empty, max_concurrent a non-negative int).
118
+ out.append(ConcurrencyClass(
119
+ name=str(item["name"]), max_concurrent=item["max_concurrent"]))
120
+ return cls(tuple(out))
121
+
122
+
123
+ # An empty registry — the kernel default (no per-kind budget, today's behavior).
124
+ NO_CLASS_BUDGETS = ClassBudgets(())
125
+
126
+
127
+ def parse_cli_budgets(pairs: list[str] | None) -> dict[str, int]:
128
+ """Parse repeatable `--class-budget KIND=N` operator flags into `{kind: N}`.
129
+
130
+ Each `pairs` item is a `"KIND=N"` string. Raises `ValueError` (operator error,
131
+ the CLI maps it to a clean contract-error exit, never a traceback) on a malformed
132
+ pair: no `=`, an empty kind, or a non-int / negative N. An empty/None list → {}.
133
+ These OVERLAY the config-declared budgets at the call boundary (a `--class-budget`
134
+ wins over a `[[concurrency_class]]` of the same name — the explicit operator flag
135
+ beats the declared default)."""
136
+ out: dict[str, int] = {}
137
+ for raw in pairs or ():
138
+ if "=" not in raw:
139
+ raise ValueError(
140
+ f"--class-budget must be KIND=N, got {raw!r} (no '=')")
141
+ kind, _, val = raw.partition("=")
142
+ kind = kind.strip()
143
+ if not kind:
144
+ raise ValueError(f"--class-budget {raw!r} has an empty KIND")
145
+ try:
146
+ n = int(val.strip())
147
+ except ValueError:
148
+ raise ValueError(
149
+ f"--class-budget {raw!r}: N must be an integer, got {val.strip()!r}"
150
+ ) from None
151
+ if n < 0:
152
+ raise ValueError(f"--class-budget {raw!r}: N must be ≥ 0, got {n}")
153
+ out[kind] = n
154
+ return out