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/tokens.py ADDED
@@ -0,0 +1,485 @@
1
+ """Central vocabulary for every dispatch verdict / outcome / blocked-reason token.
2
+
3
+ THE GLOSSARY (this docstring IS the central index the operator asked for).
4
+ ========================================================================
5
+
6
+ The dispatch family classifies each `/dispatch` iteration into a typed token.
7
+ Those tokens are written verbatim into git commit subjects
8
+ (`docs/dispatch: archive … verdict=BLOCKED`) and grepped back out by the loop to
9
+ decide continue / replan / stop. Before this module they were string literals
10
+ copy-pasted across ~290 sites in two unrelated enums with no shared home. This
11
+ module is the one place they are defined.
12
+
13
+ Gate-side verdicts (`GateVerdict`) — the INPUT gate: *did the picker produce
14
+ live, dispatchable picks?* Emitted by `/next-up` / `/dispatch` Step 9.
15
+
16
+ LIVE the packet has ≥1 dispatchable pick — /fanout ran (or will).
17
+ DRAIN a genuine empty backlog — there is nothing left to dispatch.
18
+ STALE-STAMP work shipped in git but the plan-doc rows are unstamped, so the
19
+ picker SEES a drain that is not real (a false drain).
20
+ BLOCKED picks exist but something is blocking them — a sibling claim, a
21
+ quota wall, a ship-oracle false-positive, an open operator
22
+ decision. There IS work; the loop cannot unblock it on its own.
23
+ (Renamed from the old, confusing "WEDGE" — see legacy aliases.)
24
+ RACE this render lost a candidates-cache lock race to a sibling
25
+ /next-up shell; sleep briefly + retry once (the lock will clear).
26
+
27
+ Outcome-side verdicts (`OutcomeVerdict`) — the MEASURED outcome: *after /fanout
28
+ ran, what actually landed?* Emitted by `scripts/packet_verdict.py classify`,
29
+ which measures the post-fanout commit set against the start-SHA (it does NOT
30
+ trust the input token).
31
+
32
+ SHIPPED-CLEAN every live pick produced its expected commit set. Healthy.
33
+ SHIPPED-DIRTY partial — some picks shipped, some did not (retry-able).
34
+ STALLED picks neither shipped nor cleanly failed (killed / no
35
+ result envelope) — needs operator triage.
36
+ BLOCKED-OUTCOME /fanout RAN but shipped nothing for a structural reason
37
+ (correlated outage / self-collision / decision-needed) — a
38
+ retry would re-block the same lane. (Renamed from the old
39
+ "WEDGED"; distinct name from the input-side BLOCKED so the
40
+ input-gate-vs-measured-outcome distinction stays legible.)
41
+
42
+ Why two enums, not one: the input gate ("could the picker find work?") and the
43
+ measured outcome ("did the work that ran actually land?") answer different
44
+ questions about different moments. Collapsing them lost information; they stay
45
+ distinct. Only the confusing *word* "wedge" is gone from both.
46
+
47
+ Blocked reasons (`BlockedReason` + `BLOCKED_REASONS`) — WHY a BLOCKED /
48
+ BLOCKED-OUTCOME happened. The operator-facing catalog; each reason carries a
49
+ plain-English label, whether it needs an operator decision (vs. a structural
50
+ defect the automation could fix), and a one-line fix sketch. The reason KEYS are
51
+ the same canonical keys `scripts/unstick_audit.py:CAUSES` clusters on — this
52
+ module owns the catalog, `unstick_audit` owns the cue-matching that maps an
53
+ Outcome-cell string to one of these keys. A test asserts the two stay in sync.
54
+
55
+ Legacy aliases (PERMANENT)
56
+ ==========================
57
+ Historical commits already on disk say `verdict=WEDGE` / `verdict=WEDGED`, and a
58
+ peer machine's un-pulled code (or an in-flight headless child) may still
59
+ reference `Verdict.WEDGE`. So:
60
+
61
+ * `GateVerdict.WEDGE` is a permanent Enum ALIAS of `GateVerdict.BLOCKED` (same
62
+ object — `is` works), and `OutcomeVerdict.WEDGED` aliases `BLOCKED_OUTCOME`.
63
+ * `LEGACY_TOKEN_ALIASES` maps the raw strings `"WEDGE" → "BLOCKED"` and
64
+ `"WEDGED" → "BLOCKED-OUTCOME"`. `normalize_token()` applies it, so any
65
+ consumer reading an old `verdict=WEDGE` commit transparently gets `BLOCKED`.
66
+ * `KNOWN_VERDICT_TOKENS` accepts BOTH spellings of each, so the archive-token
67
+ validator never raises on a legacy or a new token.
68
+
69
+ These aliases are never removed (no deprecation window) — the tiny permanent
70
+ cost is that the word "wedge" survives in exactly these alias lines + the
71
+ `LEGACY_TOKEN_ALIASES` map, and nowhere else in live code.
72
+
73
+ Note on `normalize_token` vs the Enum: once `WEDGE` is an alias, `WEDGE`'s
74
+ *value* is `"BLOCKED"`, so `GateVerdict("WEDGE")` RAISES (no member has value
75
+ `"WEDGE"`). That is intentional — legacy-string parsing happens at the STRING
76
+ layer via `normalize_token` BEFORE enum construction, never by feeding a legacy
77
+ string to the Enum constructor.
78
+ """
79
+ from __future__ import annotations
80
+
81
+ import enum
82
+ from dataclasses import dataclass
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Gate-side verdicts (input gate — did the picker produce live picks?)
87
+ # ---------------------------------------------------------------------------
88
+ class GateVerdict(str, enum.Enum):
89
+ """One typed gate verdict. `str`-valued so it round-trips as a bare token.
90
+
91
+ A `str`-Enum compares equal to its bare string, so
92
+ `GateVerdict.STALE_STAMP == "STALE-STAMP"` holds and the token survives a
93
+ plain-text round-trip through a commit subject without a lookup table.
94
+ """
95
+
96
+ LIVE = "LIVE"
97
+ DRAIN = "DRAIN"
98
+ STALE_STAMP = "STALE-STAMP"
99
+ BLOCKED = "BLOCKED"
100
+ # NRT2 (docs/53): the candidates-cache lock timed out — another /next-up
101
+ # shell held the plan→render window for this tag. RACE is its own typed
102
+ # verdict (not BLOCKED) because the retry semantics differ: BLOCKED is "work
103
+ # exists but blocked" (sleep doesn't help — quota window or sibling claim),
104
+ # RACE is "this exact render lost a lock race" (one short sleep + retry-once
105
+ # is the right response, and the iteration must NOT count toward the
106
+ # SHIPPED-DIRTY-0 / back-to-back ceilings — /dispatch-loop policy).
107
+ RACE = "RACE"
108
+
109
+ # --- PERMANENT legacy alias (see module docstring) -----------------------
110
+ # The old, confusing spelling. Same value object as BLOCKED, so
111
+ # `GateVerdict.WEDGE is GateVerdict.BLOCKED` and any un-migrated
112
+ # `verdict is Verdict.WEDGE` comparison keeps working forever.
113
+ WEDGE = "BLOCKED"
114
+
115
+ def __str__(self) -> str: # pragma: no cover - trivial
116
+ return self.value
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Outcome-side verdicts (measured outcome — did the work that ran land?)
121
+ # ---------------------------------------------------------------------------
122
+ class OutcomeVerdict(str, enum.Enum):
123
+ """One typed stage-3 outcome verdict. `str`-valued so it round-trips as a
124
+ token (the same pattern `GateVerdict` uses for the archive-subject token)."""
125
+
126
+ SHIPPED_CLEAN = "SHIPPED-CLEAN"
127
+ SHIPPED_DIRTY = "SHIPPED-DIRTY"
128
+ STALLED = "STALLED"
129
+ BLOCKED_OUTCOME = "BLOCKED-OUTCOME"
130
+
131
+ # --- PERMANENT legacy alias (see module docstring) -----------------------
132
+ # The old spelling. Same value object as BLOCKED_OUTCOME.
133
+ WEDGED = "BLOCKED-OUTCOME"
134
+
135
+ def __str__(self) -> str: # pragma: no cover - trivial
136
+ return self.value
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Legacy-token string aliases (the raw-string layer normalize_token applies).
141
+ # PERMANENT — never removed. The two places (plus the Enum alias members above)
142
+ # the word "wedge" survives in live code.
143
+ # ---------------------------------------------------------------------------
144
+ LEGACY_TOKEN_ALIASES: dict[str, str] = {
145
+ "WEDGE": "BLOCKED",
146
+ "WEDGED": "BLOCKED-OUTCOME",
147
+ }
148
+
149
+
150
+ def normalize_token(raw: str | None) -> str | None:
151
+ """Upper-case a raw verdict/outcome token and apply the legacy aliases.
152
+
153
+ The single chokepoint every consumer routes a grepped/parsed token through,
154
+ so a historical `verdict=WEDGE` commit transparently aggregates under
155
+ `BLOCKED` (and `WEDGED` under `BLOCKED-OUTCOME`). Returns ``None`` for a
156
+ falsy input (a README usually carries no token — that is fine). Does NOT
157
+ validate against the known set — that is `normalize_verdict_token`'s job
158
+ (which calls this first, then checks membership).
159
+
160
+ This is the layer that handles legacy strings; do NOT feed a legacy string
161
+ to `GateVerdict(...)`/`OutcomeVerdict(...)` directly — once `WEDGE` is an
162
+ alias its value is `"BLOCKED"`, so `GateVerdict("WEDGE")` raises.
163
+ """
164
+ if not raw:
165
+ return None
166
+ t = raw.strip().upper()
167
+ if not t: # whitespace-only — treat as no token
168
+ return None
169
+ return LEGACY_TOKEN_ALIASES.get(t, t)
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Known upstream archive verdict-tokens -> a coarse hint about what the token
174
+ # CLAIMED (docs/49 Open concern #3). The classifier never trusts this as the
175
+ # final word; it only uses it to detect the lie (token says LIVE, commit set is
176
+ # empty). Both legacy AND new spellings of the blocked tokens are present so the
177
+ # validator never raises on either.
178
+ # ---------------------------------------------------------------------------
179
+ KNOWN_VERDICT_TOKENS: dict[str, str] = {
180
+ "LIVE": "claims-picks-shipped",
181
+ "DRAIN": "claims-empty-backlog",
182
+ "STALE-STAMP": "claims-false-drain",
183
+ "BLOCKED": "claims-blocked",
184
+ "WEDGE": "claims-blocked", # legacy spelling of BLOCKED
185
+ "BLOCKED-OUTCOME": "claims-blocked-outcome",
186
+ "WEDGED": "claims-blocked-outcome", # legacy spelling of BLOCKED-OUTCOME
187
+ "COLLISION": "claims-sibling-or-hard-claim-collision",
188
+ "ERROR": "claims-crash",
189
+ # RATE_LIMITED — /dispatch archives this when child1/child2 envelopes carry
190
+ # a usage/quota/credit signal (scripts/rate_limit_classify). /dispatch-loop
191
+ # OutcomeKind.RATE_LIMITED stops on it and writes .resume.json so the same
192
+ # packet can be resumed without re-/next-up-ing.
193
+ "RATE_LIMITED": "claims-quota-wall",
194
+ }
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # The blocked-reason catalog — WHY a BLOCKED / BLOCKED-OUTCOME happened.
199
+ #
200
+ # The reason KEYS are the canonical cause keys `scripts/unstick_audit.py:CAUSES`
201
+ # clusters on (plus the `uncategorized_nonship` fall-through `UNCATEGORIZED`).
202
+ # This module owns the OPERATOR-FACING catalog (plain-English label, whether it
203
+ # needs an operator decision, a one-line fix sketch); `unstick_audit` owns the
204
+ # rich cue-matching that maps an Outcome-cell string to one of these keys. A
205
+ # test (`tests/test_dispatch_tokens.py`) asserts the key sets stay in sync, so
206
+ # adding a cause in one place without the other fails CI.
207
+ # ---------------------------------------------------------------------------
208
+ class BlockedReason(str, enum.Enum):
209
+ """A named, stable reason a dispatch iteration was BLOCKED.
210
+
211
+ `str`-valued and equal to the canonical `unstick_audit.Cause.key` string, so
212
+ a `cause_key` mined by `/unstick` maps to a `BlockedReason` by value.
213
+ """
214
+
215
+ STALE_CLAIM_FALSE_BLOCK = "stale_claim_false_block"
216
+ SHIP_ORACLE_FALSE_POSITIVE = "ship_oracle_false_positive"
217
+ LYING_VERDICT_TOKEN = "lying_verdict_token"
218
+ PREFLIGHT_SCRATCH_RACE = "preflight_scratch_race"
219
+ WAIT_MARKER_RUNAWAY = "wait_marker_runaway"
220
+ CHILD2_FANOUT_DIED_PRE_SHIP = "child2_fanout_died_pre_ship"
221
+ PACKET_INCOHERENCE = "packet_incoherence"
222
+ ENCODING_MOJIBAKE = "encoding_mojibake"
223
+ LANE_SOAK_GATED = "lane_soak_gated"
224
+ LANE_ALL_INFLIGHT_OR_DEFERRED = "lane_all_inflight_or_deferred"
225
+ CHILD_PARKED_PRE_STEP9_NO_ENVELOPE = "child_parked_pre_step9_no_envelope"
226
+ DATA_GATED_CLOSEOUT = "data_gated_closeout"
227
+ OPERATOR_DECISION = "operator_decision"
228
+ GATE_WEDGE_UNSPECIFIED = "gate_wedge_unspecified"
229
+ BODY_EMPTY_PICKS = "body_empty_picks"
230
+ UNCATEGORIZED_NONSHIP = "uncategorized_nonship"
231
+
232
+ def __str__(self) -> str: # pragma: no cover - trivial
233
+ return self.value
234
+
235
+
236
+ @dataclass(frozen=True)
237
+ class BlockedReasonInfo:
238
+ """The operator-facing facts about one blocked reason.
239
+
240
+ `label` is plain English (the word a confused operator reads).
241
+ `operator_action_required` distinguishes a human-gated halt (the operator
242
+ must answer something) from a structural defect the automation could fix.
243
+ `fix_sketch` is a one-line pointer to the structural fix; the full,
244
+ memory-linked fix prose lives on the matching `unstick_audit.Cause.fix`.
245
+ `self_heals_via` names the loop's own remedy when one exists
246
+ (`/replan` for a stamp/refill drift, `/unstick` for a recurring structural
247
+ defect, or "" for a human-gated reason).
248
+ """
249
+
250
+ key: str
251
+ label: str
252
+ operator_action_required: bool
253
+ fix_sketch: str
254
+ self_heals_via: str
255
+
256
+
257
+ # Keyed by BlockedReason value. The label + fix_sketch are deliberately TERSE
258
+ # (operator-report grade) — the exhaustive structural fix prose stays on the
259
+ # matching unstick_audit.Cause to avoid two copies drifting.
260
+ BLOCKED_REASONS: dict[str, BlockedReasonInfo] = {
261
+ BlockedReason.STALE_CLAIM_FALSE_BLOCK.value: BlockedReasonInfo(
262
+ key="stale_claim_false_block",
263
+ label="A stale or sibling claim is false-blocking a live pick",
264
+ operator_action_required=False,
265
+ fix_sketch="Auto-expire a working/hard claim whose deliverables are durable "
266
+ "at HEAD (or whose heartbeat exceeds TTL) inside /fanout register-prescreen.",
267
+ self_heals_via="/unstick",
268
+ ),
269
+ BlockedReason.SHIP_ORACLE_FALSE_POSITIVE.value: BlockedReasonInfo(
270
+ key="ship_oracle_false_positive",
271
+ label="The ship-oracle wrongly thinks the work already shipped",
272
+ operator_action_required=False,
273
+ fix_sketch="Add an on-disk deliverable-existence check so a (plan,phase) is "
274
+ "'shipped' only when its named artefact exists — not on a touch-count heuristic.",
275
+ self_heals_via="/unstick",
276
+ ),
277
+ BlockedReason.LYING_VERDICT_TOKEN.value: BlockedReasonInfo(
278
+ key="lying_verdict_token",
279
+ label="The archive verdict token disagrees with the actual commit set",
280
+ operator_action_required=False,
281
+ fix_sketch="Branch the loop on packet_verdict.py classify (commit-set vs "
282
+ "start-SHA) instead of trusting the prose verdict= token.",
283
+ self_heals_via="/unstick",
284
+ ),
285
+ BlockedReason.PREFLIGHT_SCRATCH_RACE.value: BlockedReasonInfo(
286
+ key="preflight_scratch_race",
287
+ label="A shared preflight scratch race resolved the wrong packet",
288
+ operator_action_required=False,
289
+ fix_sketch="Give each fanout run a per-run preflight scratch path (or assert "
290
+ "the resolved packet basename matches the fed path).",
291
+ self_heals_via="/unstick",
292
+ ),
293
+ BlockedReason.WAIT_MARKER_RUNAWAY.value: BlockedReasonInfo(
294
+ key="wait_marker_runaway",
295
+ label="A child burned its turn budget deliberating a no-pick / gated lane",
296
+ operator_action_required=False,
297
+ fix_sketch="Tighten the Scout/early-exit guard so a no-pick gated lane "
298
+ "returns a typed DRAIN/BLOCKED in <N turns instead of deliberating.",
299
+ self_heals_via="/unstick",
300
+ ),
301
+ BlockedReason.CHILD2_FANOUT_DIED_PRE_SHIP.value: BlockedReasonInfo(
302
+ key="child2_fanout_died_pre_ship",
303
+ label="A long background child (e.g. /fanout) was reaped when its parent exited pre-ship",
304
+ operator_action_required=False,
305
+ fix_sketch="Launch the long background child DETACHED (own process group / "
306
+ "new session) so a parent turn-budget exit cannot reap it; the parent then "
307
+ "adopts the survivor via its orphan-sweep. The foreground-hold fix only "
308
+ "works for a child shorter than the foreground wait ceiling.",
309
+ self_heals_via="/unstick",
310
+ ),
311
+ BlockedReason.PACKET_INCOHERENCE.value: BlockedReasonInfo(
312
+ key="packet_incoherence",
313
+ label="The dispatch packet was internally incoherent / corrupted under concurrency",
314
+ operator_action_required=False,
315
+ fix_sketch="Snapshot the scope's plan-ids immediately before render and "
316
+ "re-render under a fresh tag; HALT + log packet-corruption rather than launch.",
317
+ self_heals_via="/unstick",
318
+ ),
319
+ BlockedReason.ENCODING_MOJIBAKE.value: BlockedReasonInfo(
320
+ key="encoding_mojibake",
321
+ label="UTF-16 / encoding mangled a headless log so the verdict was unparseable",
322
+ operator_action_required=False,
323
+ fix_sketch="Decode headless stream-json logs as utf-16 (or transcode to a "
324
+ ".utf8.log sibling) before envelope-extract / verdict-grep.",
325
+ self_heals_via="/unstick",
326
+ ),
327
+ BlockedReason.LANE_SOAK_GATED.value: BlockedReasonInfo(
328
+ key="lane_soak_gated",
329
+ label="The lane's only work is soak-gated, so it has nothing dispatchable yet",
330
+ operator_action_required=False,
331
+ fix_sketch="Register the soak follow-up as soak_window_dispatchable with its "
332
+ "unblock date so /next-up picks it DURING the window instead of BLOCK-storming.",
333
+ self_heals_via="/replan",
334
+ ),
335
+ BlockedReason.LANE_ALL_INFLIGHT_OR_DEFERRED.value: BlockedReasonInfo(
336
+ key="lane_all_inflight_or_deferred",
337
+ label="The lane is DRAINED — every pick is already in-flight or deferred, "
338
+ "nothing dispatchable right now (a refill condition, not a wedge)",
339
+ operator_action_required=False,
340
+ fix_sketch="This is a drained lane, not a break — /replan refills it. The only "
341
+ "defect is when the drain reason_class (LANE_ALL_INFLIGHT_OR_DEFERRED) is "
342
+ "flattened to bare 'child2 skipped' prose with no structured cause, so /unstick "
343
+ "mis-clusters it as a generic wedge; carry the reason_class into the archive "
344
+ "Outcome cell so the next sweep routes /replan, never /unstick.",
345
+ self_heals_via="/replan",
346
+ ),
347
+ BlockedReason.CHILD_PARKED_PRE_STEP9_NO_ENVELOPE.value: BlockedReasonInfo(
348
+ key="child_parked_pre_step9_no_envelope",
349
+ label="Parked-child DRAIN — child2 skipped pre-Step-9, parent recommended "
350
+ "/replan (refill, not a wedge)",
351
+ operator_action_required=False,
352
+ # Sibling of LANE_ALL_INFLIGHT_OR_DEFERRED (same whole-lane-drain family,
353
+ # /replan route). Distinct only in HOW the drain token is recovered: the
354
+ # child2 /fanout was skipped before writing its Step-9 Outcome because the
355
+ # parent /dispatch -p exited at its wait-budget, so the structured cause is
356
+ # reconstructed from the parent's archive subject (driver Step 3.2.7) rather
357
+ # than carried from the child README. It is a MEASUREMENT ARTIFACT (the
358
+ # parent stopped waiting), NOT a break — /replan refills, /unstick is wrong.
359
+ fix_sketch="Parked-child DRAIN, not a wedge — /replan refills; never /unstick. "
360
+ "Deeper lever if it recurs at volume: child2 detachment / a longer parent "
361
+ "wait-budget so the child reaches Step 9. The routing itself is already correct.",
362
+ self_heals_via="/replan",
363
+ ),
364
+ BlockedReason.DATA_GATED_CLOSEOUT.value: BlockedReasonInfo(
365
+ key="data_gated_closeout",
366
+ label="A phase close-out is data-gated on a live run that isn't dispatchable",
367
+ operator_action_required=False,
368
+ fix_sketch="Register the close-out as a soak follow-up with a dated unblock "
369
+ "condition so the loop skips it cleanly instead of BLOCK-storming.",
370
+ self_heals_via="/replan",
371
+ ),
372
+ BlockedReason.OPERATOR_DECISION.value: BlockedReasonInfo(
373
+ key="operator_decision",
374
+ label="The lane is blocked on an open operator decision (no mechanism resolves it)",
375
+ operator_action_required=True,
376
+ fix_sketch="Type the decision into a decision-needed findings row with an "
377
+ "explicit option set so /replan promotes it to a pick the operator answers ONCE.",
378
+ self_heals_via="", # human-gated — JO escalator (Step 3.45) surfaces it once
379
+ ),
380
+ BlockedReason.GATE_WEDGE_UNSPECIFIED.value: BlockedReasonInfo(
381
+ key="gate_wedge_unspecified",
382
+ label="Blocked, but the Outcome cell named no structural cause (unroutable)",
383
+ operator_action_required=False,
384
+ fix_sketch="Tighten the /dispatch-loop Step-3 archive prose to always name "
385
+ "the blocked cause (which gate fired, which claim/oracle/decision blocked).",
386
+ self_heals_via="/unstick",
387
+ ),
388
+ BlockedReason.BODY_EMPTY_PICKS.value: BlockedReasonInfo(
389
+ key="body_empty_picks",
390
+ label="The packet rendered LIVE but its picks are body-empty "
391
+ "(dropped .prompts.json sidecar) so every /fanout refuses",
392
+ operator_action_required=False,
393
+ # FQ-420: /next-up dropped the `.prompts.json` prompt sidecar before
394
+ # returning LIVE, so preflight saw `prompt_text_len==0 AND files==[]`
395
+ # for every go-pick (`preflight._body_empty_picks`) and /fanout refused
396
+ # — a renderer defect that BLOCKS the whole dispatch path until fixed,
397
+ # not a one-off the loop can /replan around. This is the typed cause the
398
+ # `/unstick` cue for the body-empty / sidecar-drop shape maps to (the
399
+ # cue lives in unstick_audit.py:CAUSES; this is its catalog home), so a
400
+ # recurring sidecar-drop clusters under a real structural cause instead
401
+ # of vanishing into `uncategorized_nonship`. Self-heals via `/unstick`
402
+ # (the recurring-structural-defect channel), never `/replan` — a refill
403
+ # sweep cannot restore a sidecar the renderer never wrote.
404
+ fix_sketch="Make /next-up emit the `.prompts.json` sidecar (or its "
405
+ "in-packet prompt bodies) BEFORE it returns LIVE, and have the renderer "
406
+ "refuse to stamp LIVE on a packet whose picks have empty bodies.",
407
+ self_heals_via="/unstick",
408
+ ),
409
+ BlockedReason.UNCATEGORIZED_NONSHIP.value: BlockedReasonInfo(
410
+ key="uncategorized_nonship",
411
+ label="A novel blocker shape the cue table does not recognise yet",
412
+ operator_action_required=False,
413
+ fix_sketch="Read the example Outcome cell; if recurring, add a cue to "
414
+ "unstick_audit.py:CAUSES so future sweeps cluster it. A one-off routes "
415
+ "to /replan; a RECURRING uncategorized nonship escalates to /unstick "
416
+ "(see recurring_self_heal_for) — a refill sweep cannot fix a defect that "
417
+ "keeps recurring.",
418
+ self_heals_via="/replan",
419
+ ),
420
+ }
421
+
422
+
423
+ def blocked_reason_for_key(cause_key: str | None) -> BlockedReasonInfo | None:
424
+ """Map a canonical `unstick_audit` cause-key to its operator-facing catalog
425
+ entry, or ``None`` for an unknown / empty key. The lookup the dispatch-loop
426
+ report uses to name a BLOCKED reason from the recurring-wedge `cause_key`.
427
+ """
428
+ if not cause_key:
429
+ return None
430
+ return BLOCKED_REASONS.get(cause_key.strip())
431
+
432
+
433
+ # The recurrence threshold at which a generic, otherwise-/replan-routed cause is
434
+ # no longer treated as a one-off and escalates to /unstick. Mirrors
435
+ # `unstick_audit.py`'s `--min-recurrence` default (a cluster is "recurring" at
436
+ # this many affected runs). Kept here so the *routing* rule lives with the
437
+ # catalog it routes over — the job audit calls `recurring_self_heal_for` rather
438
+ # than re-encoding the threshold beside its clusterer.
439
+ RECURRENCE_ESCALATION_RUNS = 2
440
+
441
+ # The generic, prose-defect causes whose remedy DEPENDS on recurrence: a single
442
+ # occurrence is plausibly transient (route to /replan for a human read / refill),
443
+ # but a cause that keeps recurring is a structural defect /replan cannot fix —
444
+ # it must escalate to /unstick. A specific cause (a stale-claim false-block, a
445
+ # ship-oracle false-positive, a body-empty sidecar drop) already names its own
446
+ # channel and is NOT recurrence-dependent; only the catch-alls are.
447
+ _RECURRENCE_ESCALATING_KEYS = frozenset({
448
+ BlockedReason.UNCATEGORIZED_NONSHIP.value,
449
+ BlockedReason.GATE_WEDGE_UNSPECIFIED.value,
450
+ })
451
+
452
+
453
+ def recurring_self_heal_for(cause_key: str | None, runs_affected: int) -> str:
454
+ """The self-heal channel for a cause, given how many runs it has affected.
455
+
456
+ The catalog's static `self_heals_via` is the *one-off* remedy. This is the
457
+ recurrence-aware resolver the `/unstick` sweep consults: a generic
458
+ catch-all cause (`uncategorized_nonship`, `gate_wedge_unspecified`) that has
459
+ recurred across `>= RECURRENCE_ESCALATION_RUNS` runs escalates from its
460
+ default `/replan` to `/unstick` — a refill sweep cannot fix a defect that
461
+ keeps recurring, so the recurring case routes to the structural-defect
462
+ channel. This closes the FQ-420 recurring-wedge cue gap: a 5th-consecutive
463
+ `uncategorized_nonship` no longer self-heals via `/replan` forever; once it
464
+ crosses the recurrence floor it routes to `/unstick`.
465
+
466
+ A cause that names a specific structural channel (not a catch-all) returns
467
+ its catalog `self_heals_via` unchanged — recurrence does not override an
468
+ already-specific routing (a `body_empty_picks` is `/unstick` at one run, a
469
+ `lane_soak_gated` is `/replan` however often it recurs). An unknown key
470
+ returns `""` (no known remedy), matching `blocked_reason_for_key` returning
471
+ None.
472
+
473
+ PURE — the caller supplies `runs_affected` from its own cluster count
474
+ (`unstick_audit.BlockerCluster.runs_affected`); this function holds only the
475
+ routing rule, never the clustering.
476
+ """
477
+ info = blocked_reason_for_key(cause_key)
478
+ if info is None:
479
+ return ""
480
+ if (
481
+ info.key in _RECURRENCE_ESCALATING_KEYS
482
+ and runs_affected >= RECURRENCE_ESCALATION_RUNS
483
+ ):
484
+ return "/unstick"
485
+ return info.self_heals_via