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/claim_extract.py ADDED
@@ -0,0 +1,229 @@
1
+ """claim_extract — what (plan, phase) did an agent CLAIM it shipped? (docs/134 §2.1)
2
+
3
+ The crux of the runtime binding: a `Stop` hook is handed a *transcript*, but the
4
+ truth syscall (`verify`) wants `(plan, phase)`. This module is that bridge — it
5
+ reads what an agent *asserted* it finished, so the hook can check each assertion
6
+ against git. It does NOT verify anything; it only extracts the *claims* to be
7
+ verified (the oracle does the verifying).
8
+
9
+ Three rungs, strongest-first, mirroring the oracle's own evidence ladder:
10
+
11
+ 1. **MARKER** (strongest, opt-in): the agent ended a unit with a machine line
12
+ ``DOS-CLAIM: <plan> <phase>``. Lifted byte-exactly. This is where the
13
+ operator's literal "@verify-style marker" lives — the agent *declaring what
14
+ to check*, not a directive DOS executes.
15
+ 2. **FRONTMATTER** (structural): a skill whose frontmatter declares
16
+ ``dos.plan``/``dos.phase`` makes the claim known without parsing prose. That
17
+ rung lives at the hook boundary (the firing skill is known there), exposed
18
+ here as ``claim_from_frontmatter`` so both paths return the same ``Claim``.
19
+ 3. **HEURISTIC** (weakest, ABSTAINING): absent a marker, scan a "shipped/landed
20
+ /done <ID>" sentence for an explicit plan/phase-shaped token. This rung's
21
+ ONLY failure mode is a *missed* claim (the agent then stops unverified — the
22
+ safe direction); it must NEVER fabricate a `(plan, phase)`, because a
23
+ `verify` run against a hallucinated claim would make the verifier itself the
24
+ unreliable narrator it exists to catch (docs/103, inward).
25
+
26
+ The load-bearing rule, stated once: **abstain, never invent.** Free prose ("I'm
27
+ done", "shipped the auth work") yields NO claim — there is no way to know the
28
+ plan/phase *identifiers* from prose without inventing them, so the heuristic only
29
+ fires on an explicit ID-shaped token and is marked low-confidence. A design that
30
+ pretends prose alone is enough is hand-waving (docs/134 §2.1).
31
+
32
+ Shape follows ``liveness.classify`` / ``git_delta``: the **pure** extractor
33
+ (``extract_claims(text, policy) -> list[Claim]``) operates on already-read text;
34
+ the **boundary reader** (``assistant_text_from_transcript``) does the file/JSON
35
+ I/O at the call site, never inside the pure core. The transcript-parsing
36
+ convention (``message.content`` list of blocks, text from ``block["text"]``)
37
+ mirrors ``scripts/trajectory_audit.py`` so the two readers cannot drift.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import re
44
+ from dataclasses import dataclass
45
+
46
+
47
+ # The explicit marker an agent (or skill) emits to declare a verifiable claim.
48
+ # Byte-exact, anchored at a line start (after optional list/quote markup) so a
49
+ # mention *inside* prose ("emit a DOS-CLAIM: line") is not mistaken for a real
50
+ # one — only a line that IS the marker counts.
51
+ _MARKER_RE = re.compile(
52
+ r"""^[ \t>*\-]* # optional leading markup (blockquote, list bullet)
53
+ DOS-CLAIM:[ \t]+ # the literal marker
54
+ (?P<plan>[^\s]+) # plan token (no whitespace)
55
+ [ \t]+
56
+ (?P<phase>[^\s]+) # phase token (no whitespace)
57
+ [ \t]*$ # nothing else on the line
58
+ """,
59
+ re.VERBOSE | re.MULTILINE,
60
+ )
61
+
62
+ # A plan/phase-shaped identifier for the HEURISTIC rung: an uppercase-led token
63
+ # of LETTERS then DIGITS (AUTH2, FQ390, DLA3) — the shape this codebase's phases
64
+ # actually take. Deliberately narrow: it will MISS a lowercased or prose-only
65
+ # claim (safe — abstain) rather than guess. It never matches a bare English word
66
+ # (no digits) so "done" / "shipped" alone yield nothing.
67
+ _PHASE_TOKEN_RE = re.compile(r"\b([A-Z][A-Z_]*[A-Z])(\d+)\b")
68
+
69
+ # The completion verbs that gate the heuristic rung — the phase-shaped token must
70
+ # sit in a sentence that actually CLAIMS completion, not merely mentions the id.
71
+ _COMPLETION_HINT_RE = re.compile(
72
+ r"\b(shipped|landed|completed|finished|done|merged)\b", re.IGNORECASE
73
+ )
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Claim:
78
+ """One (plan, phase) an agent claimed shipped, plus how we know.
79
+
80
+ ``rung`` is ``marker`` › ``frontmatter`` › ``heuristic`` (strongest-first),
81
+ the same provenance discipline as the oracle's ``source`` tag. ``raw`` is the
82
+ text the claim was lifted from (the marker line, or the sentence), for an
83
+ auditable trail. ``confident`` is False only for the heuristic rung — a
84
+ caller may choose to treat a low-confidence claim as advisory.
85
+ """
86
+
87
+ plan: str
88
+ phase: str
89
+ rung: str
90
+ raw: str = ""
91
+
92
+ @property
93
+ def confident(self) -> bool:
94
+ return self.rung in ("marker", "frontmatter")
95
+
96
+ def to_dict(self) -> dict:
97
+ return {
98
+ "plan": self.plan,
99
+ "phase": self.phase,
100
+ "rung": self.rung,
101
+ "raw": self.raw,
102
+ "confident": self.confident,
103
+ }
104
+
105
+
106
+ def claim_from_frontmatter(plan: str | None, phase: str | None) -> list[Claim]:
107
+ """The FRONTMATTER rung: a skill declared (dos.plan, dos.phase).
108
+
109
+ Returns a single-element list when both are present, else empty. Pure — the
110
+ hook reads the frontmatter at the boundary and passes the two strings in.
111
+ """
112
+ plan = (plan or "").strip()
113
+ phase = (phase or "").strip()
114
+ if plan and phase:
115
+ return [Claim(plan=plan, phase=phase, rung="frontmatter",
116
+ raw=f"dos.plan={plan} dos.phase={phase}")]
117
+ return []
118
+
119
+
120
+ def extract_claims(text: str, *, allow_heuristic: bool = True) -> list[Claim]:
121
+ """The PURE extractor: claims an agent asserted, strongest rung first.
122
+
123
+ Operates on already-read assistant text (the boundary reader hands it over).
124
+ No I/O. Deterministic. Deduplicates on (plan, phase), keeping the strongest
125
+ rung. ``allow_heuristic=False`` restricts to the byte-exact MARKER rung — the
126
+ fail-closed posture a strict caller wants (only act on what the agent
127
+ explicitly declared).
128
+
129
+ Returns ``[]`` when nothing is confidently extractable — the abstain floor.
130
+ """
131
+ if not text:
132
+ return []
133
+
134
+ out: dict[tuple[str, str], Claim] = {}
135
+
136
+ # Rung 1 — the byte-exact marker. Strongest; always honored.
137
+ for m in _MARKER_RE.finditer(text):
138
+ plan, phase = m.group("plan"), m.group("phase")
139
+ out[(plan, phase)] = Claim(plan=plan, phase=phase, rung="marker",
140
+ raw=m.group(0).strip())
141
+
142
+ if not allow_heuristic:
143
+ return list(out.values())
144
+
145
+ # Rung 3 — the abstaining heuristic. Only fires when a phase-SHAPED token
146
+ # (AUTH2) sits in a sentence that also carries a completion verb. This never
147
+ # invents an id from prose: no ID-shaped token ⇒ no claim. A token already
148
+ # captured by the marker rung is not downgraded.
149
+ for line in text.splitlines():
150
+ if not _COMPLETION_HINT_RE.search(line):
151
+ continue
152
+ for tok in _PHASE_TOKEN_RE.finditer(line):
153
+ phase = tok.group(0) # e.g. "AUTH2"
154
+ plan = tok.group(1) # the letter stem, e.g. "AUTH"
155
+ key = (plan, phase)
156
+ if key in out:
157
+ continue # don't shadow a stronger rung
158
+ out[key] = Claim(plan=plan, phase=phase, rung="heuristic",
159
+ raw=line.strip())
160
+
161
+ return list(out.values())
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Boundary I/O — the transcript reader. NOT pure (reads a file); kept here so the
166
+ # extractor's caller has one home for the read, the git_delta discipline.
167
+ # ---------------------------------------------------------------------------
168
+ def _text_blocks(content: object) -> list[str]:
169
+ """Pull text from a message `content` (a str, or a list of typed blocks).
170
+
171
+ Mirrors scripts/trajectory_audit.py so the two transcript readers can't drift.
172
+ """
173
+ if isinstance(content, str):
174
+ return [content]
175
+ if isinstance(content, list):
176
+ texts: list[str] = []
177
+ for b in content:
178
+ if isinstance(b, dict) and b.get("type") == "text":
179
+ t = b.get("text", "")
180
+ if isinstance(t, str) and t:
181
+ texts.append(t)
182
+ return texts
183
+ return []
184
+
185
+
186
+ def assistant_text_from_transcript(path: str, *, last_turns: int = 1) -> str:
187
+ """Read the text of the last N assistant turn(s) from a transcript JSONL.
188
+
189
+ The Stop hook is told to verify "what the agent just claimed," so we read the
190
+ *tail* — the final assistant turn(s) — not the whole session (an earlier,
191
+ superseded claim must not re-trigger). Returns the concatenated text, or ``""``
192
+ on any read/parse failure (the no-crash floor: a missing/garbled transcript
193
+ yields no claims, the agent stops unverified — the safe direction).
194
+ """
195
+ if last_turns < 1:
196
+ last_turns = 1
197
+ try:
198
+ lines = _read_lines(path)
199
+ except OSError:
200
+ return ""
201
+
202
+ # Collect assistant-turn texts in order, then keep the last N.
203
+ turns: list[str] = []
204
+ for raw in lines:
205
+ raw = raw.strip()
206
+ if not raw:
207
+ continue
208
+ try:
209
+ obj = json.loads(raw)
210
+ except (ValueError, TypeError):
211
+ continue
212
+ if not isinstance(obj, dict):
213
+ continue
214
+ msg = obj.get("message")
215
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
216
+ continue
217
+ blocks = _text_blocks(msg.get("content"))
218
+ if blocks:
219
+ turns.append("\n".join(blocks))
220
+
221
+ if not turns:
222
+ return ""
223
+ return "\n".join(turns[-last_turns:])
224
+
225
+
226
+ def _read_lines(path: str) -> list[str]:
227
+ """Read a transcript file's lines (split out so a test can monkeypatch I/O)."""
228
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
229
+ return fh.readlines()
dos/claim_ttl.py ADDED
@@ -0,0 +1,150 @@
1
+ """Claim TTL / kind / status math — pure branch logic over scalars.
2
+
3
+ Lifted from the job userland's ``scripts/fanout_state.py`` (MQ3X P1, docs/62).
4
+ The kernel half of the OS7 per-status TTL model: given a claim's
5
+ ``(status, kind, expected_wallclock)`` scalars (and a frozen ``TtlPolicy`` of the
6
+ host's tuning), compute how long the claim lives. Plus the legacy-row inference
7
+ (``infer_kind`` / ``infer_status``) the ``stats`` / ``disambiguate`` verbs use to
8
+ label un-migrated rows consistently.
9
+
10
+ ``dos`` carries **mechanism, not policy** (the package-wide invariant): the OS7
11
+ minute constants are job *tuning*, so they live on a ``TtlPolicy`` dataclass the
12
+ CALLER supplies — exactly the ``LivenessPolicy`` / ``loop_decide`` thresholds
13
+ split. The defaults below match the job's historical values so a caller that
14
+ passes ``DEFAULT_POLICY`` (or nothing) is byte-identical to the pre-lift code.
15
+
16
+ Purity boundary (docs/62 §0): every function here takes scalars and returns
17
+ scalars — zero ``datetime.now`` / ``os`` / ``Path`` / ``open`` / ``yaml`` /
18
+ ``importlib``. The three job-side functions that read the CLOCK
19
+ (``_compute_expires_at`` / ``_claim_heartbeat_expired`` / ``_is_working_claim_fresh``)
20
+ keep their clock-reading WRAPPER in ``agents/leases/`` and delegate their decision
21
+ to the ``now``-injected pure cores here (``expires_at_from`` is the first; the
22
+ others land with the P3 io-layer move).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import datetime as _dt
27
+ from dataclasses import dataclass
28
+
29
+ # Claim taxonomy — the closed value sets. Carried with the inference functions
30
+ # because they decide which inferred label is "valid" (explicit field wins).
31
+ VALID_CLAIM_KINDS = ("soft", "hard", "agent_in_session")
32
+ VALID_CLAIM_STATUSES = ("working", "awaiting_decision", "awaiting_commit", "stale", "done")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class TtlPolicy:
37
+ """OS7 per-status TTL tuning — policy, not mechanism (job-side data).
38
+
39
+ The single 90-min TTL was wrong for all three claim kinds; TTL now matches the
40
+ kind's lifecycle. ``None`` resolved minutes = infinity (no ``claim_expires_at``
41
+ written). The defaults reproduce the job's historical constants exactly:
42
+
43
+ awaiting_commit_minutes — 24h, drives the OS2 auto-archive cadence.
44
+ agent_in_session_minutes — 6h, one operator session (overrides status).
45
+ default_working_wallclock_minutes — used when ``expected_wallclock`` is absent
46
+ (matches pre-OS7 ``--ttl-minutes 90`` callsites).
47
+ working_ttl_multiplier — working TTL = ``expected_wallclock × multiplier``.
48
+ """
49
+
50
+ awaiting_commit_minutes: int = 24 * 60
51
+ agent_in_session_minutes: int = 6 * 60
52
+ default_working_wallclock_minutes: int = 30
53
+ working_ttl_multiplier: int = 3
54
+
55
+ def __post_init__(self) -> None:
56
+ for name in (
57
+ "awaiting_commit_minutes", "agent_in_session_minutes",
58
+ "default_working_wallclock_minutes", "working_ttl_multiplier",
59
+ ):
60
+ if getattr(self, name) < 0:
61
+ raise ValueError(f"TtlPolicy.{name} must be non-negative")
62
+
63
+
64
+ DEFAULT_POLICY = TtlPolicy()
65
+
66
+
67
+ def infer_kind(
68
+ entry: dict,
69
+ dispatcher_kinds: tuple[tuple[str, str], ...] = (),
70
+ ) -> str:
71
+ """Infer claim_kind for legacy rows that pre-date the 2026-05-13 schema
72
+ addition. Used by ``disambiguate`` and ``stats`` so the operator sees a
73
+ consistent kind label even on un-migrated rows. Never written back to disk;
74
+ the explicit field on new rows takes precedence.
75
+
76
+ ``dispatcher_kinds`` is the host's ``(dispatched_by-prefix, claim_kind)`` map for
77
+ the legacy fallback — the prefixes name a host's dispatcher SKILLS (e.g. the
78
+ reference app's ``fanout-`` → ``hard`` / ``next-up-`` → ``soft``), so the kernel
79
+ hardcodes NONE of them (userland-coupling audit 2026-06-08): the host passes its
80
+ own. Pairs are tried in order; the first matching prefix wins. Empty (the kernel
81
+ default) means a row with no explicit kind / TTL is simply ``unknown``."""
82
+ if entry.get("claim_kind") in VALID_CLAIM_KINDS:
83
+ return entry["claim_kind"]
84
+ if entry.get("claim_expires_at"):
85
+ return "soft"
86
+ by = (entry.get("dispatched_by") or "").lower()
87
+ for prefix, kind in dispatcher_kinds:
88
+ if by.startswith(prefix.lower()):
89
+ return kind
90
+ return "unknown"
91
+
92
+
93
+ def infer_status(entry: dict) -> str:
94
+ """Infer claim_status. Returns 'working' for in_progress rows lacking explicit
95
+ claim_status; the precise working/stale split needs heartbeat substrate."""
96
+ if entry.get("claim_status") in VALID_CLAIM_STATUSES:
97
+ return entry["claim_status"]
98
+ if entry.get("status") == "in_progress":
99
+ return "working"
100
+ return entry.get("status", "unknown")
101
+
102
+
103
+ def expected_wallclock(claim_kind: str, ttl_minutes: int | None) -> int:
104
+ """Default expected wallclock per claim_kind. Used by the stale detector when
105
+ an explicit ``expected_wallclock_minutes`` isn't set on the row."""
106
+ if ttl_minutes:
107
+ return ttl_minutes
108
+ if claim_kind == "soft":
109
+ return 90
110
+ if claim_kind == "agent_in_session":
111
+ return 60 # in-conversation agents tend to be quicker
112
+ return 360 # hard fanout default — agents can take up to a few hours
113
+
114
+
115
+ def resolve_ttl_minutes(
116
+ claim_status: str, claim_kind: str,
117
+ expected_wallclock_minutes: int | None,
118
+ policy: TtlPolicy = DEFAULT_POLICY,
119
+ ) -> int | None:
120
+ """OS7 — per-status TTL formula. Returns minutes until claim expiry, or
121
+ ``None`` for infinity (no TTL field written).
122
+
123
+ Resolution order:
124
+ 1. claim_kind=agent_in_session → 6h (in-conversation agents shouldn't linger
125
+ past one operator session; overrides status formula).
126
+ 2. claim_status=working → expected_wallclock × multiplier (defaults to
127
+ ``policy.default_working_wallclock_minutes`` when expected_wallclock is
128
+ absent — matches pre-OS7 behaviour for legacy ``--ttl-minutes 90``).
129
+ 3. claim_status=awaiting_commit → 24h (drives OS2 auto-archive).
130
+ 4. claim_status=awaiting_decision / stale → None (operator drives resolution).
131
+ """
132
+ if claim_kind == "agent_in_session":
133
+ return policy.agent_in_session_minutes
134
+ if claim_status == "working":
135
+ ew = expected_wallclock_minutes or policy.default_working_wallclock_minutes
136
+ return ew * policy.working_ttl_multiplier
137
+ if claim_status == "awaiting_commit":
138
+ return policy.awaiting_commit_minutes
139
+ return None
140
+
141
+
142
+ def expires_at_from(now: _dt.datetime, ttl_minutes: int | None) -> str | None:
143
+ """Pure core of ``_compute_expires_at``: convert a TTL in minutes (or
144
+ None=infinity) to an ISO ``%Y-%m-%dT%H:%MZ`` timestamp, given an injected
145
+ ``now``. The clock-reading wrapper (``agents/leases/ttl.py``) supplies
146
+ ``dt.datetime.now(dt.timezone.utc)``; this stays pure + replay-testable."""
147
+ if ttl_minutes is None:
148
+ return None
149
+ expiry = now + _dt.timedelta(minutes=ttl_minutes)
150
+ return expiry.strftime("%Y-%m-%dT%H:%MZ")