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/preflight.py ADDED
@@ -0,0 +1,825 @@
1
+ """One-shot preflight bundler for `/fanout-true-headless-multi-agent`.
2
+
3
+ Replaces Steps 1.5 + 1.6 + 1.6.5 + 1.7 + 1.8 of the SKILL — four separate
4
+ Bash subcommands and ~5 paragraphs of prose decision logic — with one Bash
5
+ call returning a compact JSON blob the orchestrator branches on.
6
+
7
+ Audit motivating this (session 8ac5898a, 2026-05-19): the fanout SKILL.md
8
+ was 819 lines and the orchestrator was burning context re-deriving the same
9
+ preflight verdicts (packet staleness, in-flight collision, wave grouping,
10
+ register conflict gate) that already-shipped helpers
11
+ (`check_phase_shipped.py`, `fanout_state.py`, `fanout_archive_lock.py`)
12
+ produce mechanically. The skill described their behavior in prose rather
13
+ than just calling them.
14
+
15
+ Full audit + recipe: docs/_audits/skill-context-bundling-2026-05-11.md
16
+ docs/_audits/fanout-context-audit-2026-05-19.md (this audit).
17
+
18
+ Usage:
19
+ python scripts/fanout_preflight_context.py <packet-path>
20
+ python scripts/fanout_preflight_context.py <packet-path> --pretty
21
+
22
+ Output schema (top-level keys):
23
+ schema_version int — bump on breaking change
24
+ generated_at str — ISO-8601 UTC
25
+ packet dict — {path, last_sha, drift_commits, schema,
26
+ packet_schema, expected_packet_schema,
27
+ schema_drift, schema_drift_reason}
28
+ schema_drift=True (OC4) means the packet's
29
+ header schema token is absent/mismatched —
30
+ the orchestrator must NOT silently launch.
31
+ picks list — [{n, plan, phase, phase_chain, gates_on,
32
+ files (truncated), prompt_text_len,
33
+ shipped, in_flight_collision, verdict,
34
+ drop_reason}, ...]
35
+ verdict ∈ {go, shipped, collision,
36
+ unknown}; drop_reason set when
37
+ verdict != go.
38
+ waves list — [[pick_n, ...], ...] partition by gates_on
39
+ drop_list list — verdict != go picks, with reason
40
+ live_count int — count of go-verdict picks
41
+ dirty_tree dict — {start_sha, modified_files, untracked_count,
42
+ truncated_at}
43
+ archive_lock dict — {state, prev_owner?, prev_age_s?}
44
+ in_flight_overlap_phases list — phase-ids in_flight (filtered to picks)
45
+
46
+ The helper is read-only — never mutates files or registry. The SKILL still
47
+ calls `fanout_state.py register` (write) at Step 1.8 after consuming this.
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import argparse
52
+ import datetime as dt
53
+ import json
54
+ import os
55
+ import re
56
+ import subprocess
57
+ import sys
58
+ from pathlib import Path
59
+
60
+ from dos import config as _config
61
+ from dos.packet_sidecar import SIDECAR_SCHEMA
62
+
63
+
64
+ def _workspace_root() -> Path:
65
+ """The served workspace (where git, plan docs, and run-dirs live).
66
+
67
+ Honors `DISPATCH_WORKSPACE` for a test/fixture redirect, then the active
68
+ config. (The job preflight test repoints the workspace at a tmp dir; under
69
+ the separation refactor it does so via this env var instead of monkeypatching
70
+ a module constant that no longer exists.)
71
+ """
72
+ env = os.environ.get("DISPATCH_WORKSPACE")
73
+ if env:
74
+ return Path(env)
75
+ return _config.active().paths.root
76
+
77
+
78
+ def _next_up_dir() -> Path:
79
+ env = os.environ.get("DISPATCH_NEXT_UP_DIR")
80
+ if env:
81
+ return Path(env)
82
+ return _config.active().paths.next_packets
83
+
84
+
85
+ SCHEMA_VERSION = 2 # +verdict_envelope / refuse (FQ-410)
86
+ DIRTY_TREE_CAP = 50 # don't bloat output with a 5k-file untracked list
87
+ FILES_CAP_PER_PICK = 20 # paths > 20 truncated; full list lives in sidecar
88
+
89
+ # The launchable-verdict set + the envelope-refusal judgement now live in ONE
90
+ # place, `wedge_reason` (`LAUNCHABLE_VERDICTS` / `envelope_is_refusal`), shared
91
+ # with `decisions`; `_envelope_refusal` below delegates there.
92
+
93
+ # OC4 (2026-05-19) — the packet-schema token the /next-up renderer
94
+ # (`scripts/next_up_render.py:PACKET_SCHEMA`) stamps in the packet's markdown
95
+ # header. This preflight reads the packet's marker and compares: a missing or
96
+ # mismatched token means the /next-up that wrote the packet is out of contract
97
+ # with this /fanout, so the orchestrator must NOT silently launch against a
98
+ # drifted packet. Keep this in lockstep with the renderer's constant — the two
99
+ # being equal IS the handoff contract. ⚓ feedback_mechanical_contract_over_prose.
100
+ EXPECTED_PACKET_SCHEMA = "next-up-packet-v1"
101
+
102
+
103
+ def _feature_flags_view() -> dict:
104
+ """Surface the operator-mutable dispatch feature flags relevant to /fanout.
105
+
106
+ Reads via the canonical accessors in `next_up_context` (the same read-path
107
+ /next-up uses) so there is ONE loader for execution-state.yaml's
108
+ `feature_flags:` block — no second parser here. Returns the resolved model
109
+ for the `fanout.child` grandchild section (env+yaml honored) plus the raw
110
+ overrides map, so the orchestrator can pass the right `--model` at the
111
+ grandchild launch step without importing the registry itself. Defensive:
112
+ any import/resolution failure degrades to an empty/quiet view rather than
113
+ breaking the whole preflight bundle.
114
+ """
115
+ view: dict[str, object] = {}
116
+ try:
117
+ sys.path.insert(0, str(_workspace_root() / "scripts"))
118
+ import next_up_context as _nuc # noqa: E402
119
+ view["lane_leasing"] = _nuc.lane_leasing_enabled()
120
+ view["focus_auto"] = _nuc.focus_auto_enabled()
121
+ view["model_overrides"] = _nuc.feature_flags().get("models") or {}
122
+ except Exception:
123
+ view.setdefault("model_overrides", {})
124
+ try:
125
+ import model_registry as _mr # noqa: E402
126
+ view["fanout_child_model"] = _mr.resolve_model("fanout.child")
127
+ except Exception:
128
+ pass
129
+ return view
130
+
131
+
132
+ def _run(cmd: list[str], *, timeout: int = 30) -> tuple[int, str, str]:
133
+ """Run a subprocess, return (exit, stdout, stderr). Never raises."""
134
+ try:
135
+ p = subprocess.run(
136
+ cmd,
137
+ cwd=str(_workspace_root()),
138
+ capture_output=True,
139
+ text=True,
140
+ encoding="utf-8",
141
+ errors="replace",
142
+ timeout=timeout,
143
+ )
144
+ return p.returncode, p.stdout, p.stderr
145
+ except FileNotFoundError as e:
146
+ return 127, "", f"FileNotFoundError: {e}"
147
+ except subprocess.TimeoutExpired:
148
+ return 124, "", f"timeout after {timeout}s"
149
+ except Exception as e: # pragma: no cover — defensive
150
+ return 1, "", f"{type(e).__name__}: {e}"
151
+
152
+
153
+ def _python() -> str:
154
+ """Return the venv python interpreter, with PowerShell-style fallback."""
155
+ root = _workspace_root()
156
+ candidates = [
157
+ root / ".venv" / "Scripts" / "python.exe",
158
+ root / ".venv" / "bin" / "python",
159
+ ]
160
+ for c in candidates:
161
+ if c.exists():
162
+ return str(c)
163
+ return sys.executable # fall back to the running interpreter
164
+
165
+
166
+ # The three states the `.prompts.json` prompt sidecar can be in, reported on
167
+ # the loader's `sidecar_status` field. FQ-420: the markdown fallback used to
168
+ # collapse `absent` and `corrupt` into a bare `source="markdown"`, hiding the
169
+ # fact that the renderer DROPPED the prompt bodies — the operator saw only the
170
+ # downstream symptom (`body_empty_picks`), never the root cause. Naming the
171
+ # status lets the refuse gate point straight at the dropped sidecar.
172
+ SIDECAR_PRESENT = "present" # `.prompts.json` existed and parsed — prompts loaded
173
+ SIDECAR_ABSENT = "absent" # `.prompts.json` did not exist — renderer never wrote it
174
+ SIDECAR_CORRUPT = "corrupt" # `.prompts.json` existed but was unreadable / bad JSON
175
+
176
+
177
+ def load_packet_sidecar(packet_path: Path) -> dict:
178
+ """Prefer the `.prompts.json` sidecar over markdown parsing.
179
+
180
+ Returns {schema, picks, source, sidecar_path, sidecar_status}. `source` is
181
+ 'sidecar' (prompts loaded from the sidecar), 'markdown' (fell back to header
182
+ parsing), or 'missing' (the packet itself was unreadable). `sidecar_status`
183
+ is the FQ-420 distinguisher — one of SIDECAR_PRESENT / SIDECAR_ABSENT /
184
+ SIDECAR_CORRUPT — so a caller can tell a dropped sidecar (the renderer never
185
+ emitted the prompt bodies) apart from a corrupt one, and from a clean
186
+ markdown packet that genuinely has no sidecar. The skill's Step 2 already
187
+ prefers the sidecar; this preflight does the same.
188
+ """
189
+ sidecar = packet_path.with_name(packet_path.stem + ".prompts.json")
190
+ sidecar_status = SIDECAR_ABSENT
191
+ if sidecar.exists():
192
+ try:
193
+ with open(sidecar, encoding="utf-8") as f:
194
+ d = json.load(f)
195
+ # Repo-relative for display when under the workspace; fall back to
196
+ # the absolute path otherwise (a sidecar outside the workspace — e.g.
197
+ # a tmp_path fixture — must not crash the loader, the same guard
198
+ # `packet_freshness` applies to the packet path).
199
+ try:
200
+ disp_sidecar = str(sidecar.relative_to(_workspace_root())).replace(os.sep, "/")
201
+ except ValueError:
202
+ disp_sidecar = str(sidecar).replace(os.sep, "/")
203
+ return {
204
+ "schema": d.get("schema", SIDECAR_SCHEMA),
205
+ "picks": d.get("picks", []),
206
+ "source": "sidecar",
207
+ "sidecar_path": disp_sidecar,
208
+ "sidecar_status": SIDECAR_PRESENT,
209
+ }
210
+ except (OSError, json.JSONDecodeError):
211
+ # The sidecar is on disk but unreadable — a corrupt/half-written
212
+ # drop, distinct from one that was never written. Record CORRUPT so
213
+ # the refuse gate names it precisely, then fall through to markdown.
214
+ sidecar_status = SIDECAR_CORRUPT
215
+ # Markdown fallback: parse `### N. <PLAN> <PHASE> — <title>` headers.
216
+ # Conservative — we do not extract prompt_text from markdown here; if the
217
+ # sidecar is missing, the SKILL falls back to its existing markdown path.
218
+ # The picks produced here have empty bodies (`prompt_text=""`, `files=[]`);
219
+ # when the packet DID render picks, that empties them downstream — which is
220
+ # exactly why `sidecar_status` is carried out, so the refuse gate can blame
221
+ # the dropped sidecar rather than the (symptomatically) empty picks.
222
+ picks: list[dict] = []
223
+ try:
224
+ text = packet_path.read_text(encoding="utf-8")
225
+ except OSError:
226
+ return {
227
+ "schema": "unknown", "picks": [], "source": "missing",
228
+ "sidecar_path": None, "sidecar_status": sidecar_status,
229
+ }
230
+ header_re = re.compile(r"^###\s+(\d+)\.\s+([A-Z][A-Za-z0-9]*)\s+(\S+)\s+—\s+(.+)$", re.MULTILINE)
231
+ for m in header_re.finditer(text):
232
+ n, plan, phase, title = m.groups()
233
+ picks.append({
234
+ "n": int(n),
235
+ "plan_id": plan,
236
+ "phase_id": phase,
237
+ "phase_title": title.strip(),
238
+ "phase_chain": [phase],
239
+ "doc_path": None,
240
+ "files": [],
241
+ "reserve_paths": [],
242
+ "gates_on": [],
243
+ "prompt_text": "",
244
+ })
245
+ return {
246
+ "schema": "markdown-fallback",
247
+ "picks": picks,
248
+ "source": "markdown",
249
+ "sidecar_path": None,
250
+ "sidecar_status": sidecar_status,
251
+ }
252
+
253
+
254
+ def packet_freshness(packet_path: Path) -> dict:
255
+ """Read 'Last commit: `<sha>`' + 'Packet schema: `<token>`' header lines.
256
+
257
+ The `Last commit` sha drives the drift-count diff against HEAD. The OC4
258
+ `Packet schema` token drives the handoff-contract check: a missing or
259
+ mismatched token is reported as `schema_drift: true` so the orchestrator
260
+ can refuse to launch on a packet whose /next-up is out of contract — the
261
+ OC-P4 additive-silent failure mode made loud.
262
+ """
263
+ last_sha = None
264
+ packet_schema: str | None = None
265
+ try:
266
+ with open(packet_path, encoding="utf-8") as f:
267
+ for i, line in enumerate(f):
268
+ if last_sha is None:
269
+ m = re.search(r"Last commit:\s*`([0-9a-f]{7,40})`", line)
270
+ if m:
271
+ last_sha = m.group(1)
272
+ if packet_schema is None:
273
+ sm = re.search(r"Packet schema:\s*`([^`]+)`", line)
274
+ if sm:
275
+ packet_schema = sm.group(1).strip()
276
+ if last_sha is not None and packet_schema is not None:
277
+ break
278
+ if i > 20: # bounded header scan — don't read the whole packet
279
+ break
280
+ except OSError:
281
+ pass
282
+ # OC4 handoff-contract check. A pre-OC4 packet carries no `Packet schema`
283
+ # line (packet_schema is None) → drift with a "pre-v1" reason; a packet
284
+ # whose token differs from EXPECTED_PACKET_SCHEMA → drift with a mismatch
285
+ # reason. Both block a silent launch.
286
+ if packet_schema is None:
287
+ schema_drift = True
288
+ schema_drift_reason = (
289
+ "packet has no `Packet schema` marker — written by a pre-OC4 "
290
+ "/next-up; re-run /next-up for a versioned packet"
291
+ )
292
+ elif packet_schema != EXPECTED_PACKET_SCHEMA:
293
+ schema_drift = True
294
+ schema_drift_reason = (
295
+ f"packet schema {packet_schema!r} != expected "
296
+ f"{EXPECTED_PACKET_SCHEMA!r} — the /next-up that wrote this packet "
297
+ f"is out of contract with this /fanout"
298
+ )
299
+ else:
300
+ schema_drift = False
301
+ schema_drift_reason = None
302
+ drift = 0
303
+ drift_commits: list[str] = []
304
+ if last_sha:
305
+ rc, out, _ = _run(["git", "log", "--oneline", f"{last_sha}..HEAD"], timeout=15)
306
+ if rc == 0:
307
+ all_drift = [l for l in out.splitlines() if l.strip()]
308
+ drift = len(all_drift)
309
+ # Keep just enough for the SKILL to spot if a recent commit on
310
+ # main shipped something the picks gate on. 10 is plenty.
311
+ drift_commits = all_drift[:10]
312
+ # Repo-relative path for display when the packet lives under the repo;
313
+ # fall back to the absolute path otherwise (a packet outside REPO_ROOT —
314
+ # e.g. a tmp_path fixture — must not crash the read).
315
+ if packet_path.is_absolute():
316
+ try:
317
+ disp_path = str(packet_path.relative_to(_workspace_root())).replace(os.sep, "/")
318
+ except ValueError:
319
+ disp_path = str(packet_path).replace(os.sep, "/")
320
+ else:
321
+ disp_path = str(packet_path)
322
+ return {
323
+ "path": disp_path,
324
+ "last_sha": last_sha,
325
+ "drift_commits": drift_commits,
326
+ "drift_count": drift,
327
+ "packet_schema": packet_schema,
328
+ "expected_packet_schema": EXPECTED_PACKET_SCHEMA,
329
+ "schema_drift": schema_drift,
330
+ "schema_drift_reason": schema_drift_reason,
331
+ }
332
+
333
+
334
+ def packet_shipped_verdict(packet_path: Path) -> dict:
335
+ """Run `check_phase_shipped.py --check-packet` and parse its table.
336
+
337
+ Output we care about: per-pick verdict (KEEP / DROP) and cited sha if any.
338
+ Exit codes (--check-packet): 0=any shipped, 1=all clean, 2=no coverage,
339
+ 3=parse error.
340
+ """
341
+ rc, out, err = _run(
342
+ [_python(), "-m", "dos.phase_shipped", "--check-packet", str(packet_path)],
343
+ timeout=60,
344
+ )
345
+ # Parse " DROP <SERIES> <PHASE> shipped in <sha>" / " KEEP <SERIES> <PHASE>"
346
+ by_phase: dict[str, dict] = {}
347
+ line_re = re.compile(r"^\s*(KEEP|DROP)\s+([A-Z][A-Za-z0-9]*)\s+(\S+)\s*(?:shipped in\s+([0-9a-f]+))?\s*$")
348
+ for line in out.splitlines():
349
+ m = line_re.match(line)
350
+ if m:
351
+ verdict, plan, phase, sha = m.groups()
352
+ by_phase[f"{plan}/{phase}"] = {
353
+ "verdict": verdict,
354
+ "shipped_sha": sha,
355
+ }
356
+ return {
357
+ "exit_code": rc,
358
+ "by_phase": by_phase,
359
+ "raw_tail": "\n".join(out.splitlines()[-10:]) if out else "",
360
+ "stderr_tail": "\n".join((err or "").splitlines()[-5:]),
361
+ }
362
+
363
+
364
+ # claim_status values that mean the claim is no longer a live block on its
365
+ # (plan, phase) — mirrors next_up_context._DEAD_CLAIM_STATUSES on the job side.
366
+ # A row carrying one of these is terminal even if its legacy `status` field
367
+ # still says in_progress, so it must not count as an in-flight overlap.
368
+ _TERMINAL_CLAIM_STATUSES = frozenset({"done", "stale", "released", "expired"})
369
+
370
+
371
+ def list_active_filtered(
372
+ pick_phases: set[str], own_packet_basename: str | None = None
373
+ ) -> tuple[list[dict], list[str]]:
374
+ """Call fanout_state list-active and filter to entries overlapping picks.
375
+
376
+ Returns (filtered_rows, overlapping_phase_ids). Full output is ~50KB;
377
+ filtered output is typically <2KB.
378
+
379
+ own_packet_basename: when provided, soft-claim rows whose dispatched_by
380
+ matches this packet are excluded — they are this packet's own freshly-
381
+ written soft-claims, not in-flight work from another packet. Closes the
382
+ self-collision recurrence (#4) where /next-up's pre-write of soft-claims
383
+ wedges the very /fanout it hands off to.
384
+ """
385
+ rc, out, _ = _run(
386
+ [_python(), "scripts/fanout_state.py", "list-active", "--json"],
387
+ timeout=30,
388
+ )
389
+ if rc != 0 or not out.strip():
390
+ return [], []
391
+ try:
392
+ rows = json.loads(out)
393
+ except json.JSONDecodeError:
394
+ return [], []
395
+ overlap_phases: list[str] = []
396
+ filtered: list[dict] = []
397
+ for r in rows:
398
+ if r.get("status") not in ("in_progress", "stalled", "open"):
399
+ continue
400
+ # FQ-336 (2026-06-05): a claim whose claim_status is terminal
401
+ # (done/stale/released/expired) is NOT a live block on its phase, even
402
+ # while its legacy `status` field still lags at in_progress (the writer
403
+ # flipped claim_status but not status; the terminal-active-work sweep
404
+ # only drains it after a 14-day grace window). Treating it as an in-flight
405
+ # overlap would re-block a phase the /next-up picker already correctly
406
+ # freed (next_up_context._trim_active_work drops the same rows for the
407
+ # picker bundle) — the false-collision twin of the picker false-DRAIN.
408
+ if str(r.get("claim_status") or "").strip().lower() in _TERMINAL_CLAIM_STATUSES:
409
+ continue
410
+ plan = r.get("plan") or ""
411
+ phase = r.get("phase") or ""
412
+ key = f"{plan}/{phase}"
413
+ if key in pick_phases or phase in pick_phases:
414
+ dispatched_by = r.get("dispatched_by") or ""
415
+ if own_packet_basename and dispatched_by == own_packet_basename:
416
+ continue
417
+ overlap_phases.append(phase)
418
+ filtered.append({
419
+ "id": r.get("id"),
420
+ "plan": plan,
421
+ "phase": phase,
422
+ "title": (r.get("title") or "")[:120],
423
+ "dispatched_by": dispatched_by,
424
+ "claim_kind": r.get("claim_kind"),
425
+ "claim_status": r.get("claim_status"),
426
+ "dispatched_at": r.get("dispatched_at"),
427
+ })
428
+ return filtered, overlap_phases
429
+
430
+
431
+ def dirty_tree_state() -> dict:
432
+ """Snapshot current working tree state — start_sha + bounded mod/untracked list."""
433
+ rc_sha, sha_out, _ = _run(["git", "rev-parse", "HEAD"], timeout=10)
434
+ start_sha = sha_out.strip()[:12] if rc_sha == 0 else None
435
+ rc_st, st_out, _ = _run(["git", "status", "--short"], timeout=10)
436
+ modified: list[str] = []
437
+ untracked: list[str] = []
438
+ truncated = False
439
+ if rc_st == 0:
440
+ for line in st_out.splitlines():
441
+ if not line.strip():
442
+ continue
443
+ tag = line[:2]
444
+ path = line[3:].strip()
445
+ if tag.startswith("??"):
446
+ untracked.append(path)
447
+ else:
448
+ modified.append(f"{tag} {path}")
449
+ total = len(modified) + len(untracked)
450
+ if total > DIRTY_TREE_CAP:
451
+ truncated = True
452
+ keep = max(1, DIRTY_TREE_CAP // 2)
453
+ modified = modified[:keep]
454
+ untracked = untracked[:DIRTY_TREE_CAP - len(modified)]
455
+ return {
456
+ "start_sha": start_sha,
457
+ "modified": modified,
458
+ "untracked": untracked,
459
+ "untracked_count_full": len([l for l in st_out.splitlines() if l.startswith("??")]) if rc_st == 0 else 0,
460
+ "truncated_at": DIRTY_TREE_CAP if truncated else None,
461
+ }
462
+
463
+
464
+ def archive_lock_state() -> dict:
465
+ """Probe the Step 9.5 mutex's current state without acquiring it."""
466
+ rc, out, _ = _run(
467
+ [_python(), "-m", "dos.archive_lock", "status"],
468
+ timeout=10,
469
+ )
470
+ state = (out or "").strip().splitlines()[0] if out.strip() else ""
471
+ if state == "free":
472
+ return {"state": "free"}
473
+ # Held shapes: "held <owner> age=<s>s" / "held-stale <owner> age=<s>s"
474
+ m = re.match(r"^(held|held-stale)\s+(\S+)(?:\s+age=(\d+)s)?", state)
475
+ if m:
476
+ return {
477
+ "state": m.group(1),
478
+ "prev_owner": m.group(2),
479
+ "prev_age_s": int(m.group(3)) if m.group(3) else None,
480
+ }
481
+ return {"state": state or "unknown", "raw": out[:200] if out else ""}
482
+
483
+
484
+ def partition_waves(picks_with_verdict: list[dict]) -> list[list[int]]:
485
+ """Partition live (go-verdict) picks into launch waves by gates_on.
486
+
487
+ Returns [[pick_n, ...], ...] — wave 1 = roots (gates_on empty),
488
+ wave 2 = picks whose gates_on ⊆ wave-1 phases, etc.
489
+ """
490
+ live = [p for p in picks_with_verdict if p.get("verdict") == "go"]
491
+ if not live:
492
+ return []
493
+ placed: set[str] = set() # phase ids already in a wave
494
+ waves: list[list[int]] = []
495
+ remaining = list(live)
496
+ safety = 10 # cap on wave count — packets >10 waves are pathological
497
+ by_n = {p["n"]: p for p in live}
498
+ while remaining and safety > 0:
499
+ safety -= 1
500
+ wave_ns: list[int] = []
501
+ next_remaining: list[dict] = []
502
+ for p in remaining:
503
+ gates = [g for g in (p.get("gates_on") or []) if g]
504
+ if all(g in placed for g in gates):
505
+ wave_ns.append(p["n"])
506
+ else:
507
+ next_remaining.append(p)
508
+ if not wave_ns:
509
+ # cycle or dangling gate — bail; orchestrator will see remainder
510
+ # in drop_list (the SKILL's existing dangling-edge rule applies).
511
+ break
512
+ for n in wave_ns:
513
+ placed.add(by_n[n]["phase"])
514
+ waves.append(wave_ns)
515
+ remaining = next_remaining
516
+ return waves
517
+
518
+
519
+ def merge_picks_with_verdicts(
520
+ sidecar_picks: list[dict],
521
+ shipped: dict,
522
+ in_flight_phases: list[str],
523
+ ) -> tuple[list[dict], list[dict]]:
524
+ """Produce the merged picks list + drop_list."""
525
+ picks_out: list[dict] = []
526
+ drops: list[dict] = []
527
+ in_flight_set = set(in_flight_phases)
528
+ shipped_by_phase = shipped.get("by_phase", {})
529
+ for p in sidecar_picks:
530
+ plan = p.get("plan_id") or ""
531
+ phase = p.get("phase_id") or ""
532
+ key = f"{plan}/{phase}"
533
+ files = p.get("files") or []
534
+ files_full_count = len(files)
535
+ if files_full_count > FILES_CAP_PER_PICK:
536
+ files = files[:FILES_CAP_PER_PICK] + [f"… ({files_full_count - FILES_CAP_PER_PICK} more — see sidecar)"]
537
+
538
+ shipped_entry = shipped_by_phase.get(key)
539
+ verdict = "go"
540
+ drop_reason = None
541
+ if shipped_entry and shipped_entry.get("verdict") == "DROP":
542
+ verdict = "shipped"
543
+ drop_reason = f"shipped in {shipped_entry.get('shipped_sha') or '?'}"
544
+ elif phase in in_flight_set:
545
+ verdict = "collision"
546
+ drop_reason = "in-flight in registry (overlap detected)"
547
+
548
+ # OC4 anchor #4 — carry the pick's kind so the fanout/dispatch overlap
549
+ # consumers can branch the same way the renderer's _matrix does: a
550
+ # synthetic findings pick's `files` are routing pointers, not a
551
+ # code-touch footprint. Default `code`; infer `finding` from an explicit
552
+ # field or the FQ- phase-id convention.
553
+ pick_kind = str(p.get("pick_kind") or "").strip().lower()
554
+ if pick_kind not in ("code", "finding"):
555
+ pick_kind = "finding" if (phase.upper().startswith("FQ-") or p.get("is_synthetic")) else "code"
556
+
557
+ row = {
558
+ "n": p.get("n"),
559
+ "plan": plan,
560
+ "phase": phase,
561
+ "phase_chain": p.get("phase_chain") or [phase],
562
+ "phase_title": p.get("phase_title") or "",
563
+ "gates_on": p.get("gates_on") or [],
564
+ "files": files,
565
+ "files_full_count": files_full_count,
566
+ "doc_path": p.get("doc_path"),
567
+ "subagent_type": p.get("subagent_type"),
568
+ "mode": p.get("mode"),
569
+ "pick_kind": pick_kind,
570
+ "prompt_text_len": len(p.get("prompt_text") or ""),
571
+ "verdict": verdict,
572
+ }
573
+ if drop_reason:
574
+ row["drop_reason"] = drop_reason
575
+ drops.append({"n": row["n"], "plan": plan, "phase": phase, "reason": drop_reason})
576
+ picks_out.append(row)
577
+ return picks_out, drops
578
+
579
+
580
+ def read_verdict_envelope(tag: str) -> dict | None:
581
+ """Read `output/next-up/.verdict-<tag>.json` if present (FQ-410).
582
+
583
+ The /next-up renderer / WEDGE-emitter writes this envelope for every run —
584
+ LIVE-shaped on a real packet, or `verdict=WEDGE|DRAIN`/`do_not_render` when
585
+ the lane was refused. The preflight was BLIND to it: a packet pre-routed
586
+ `verdict=WEDGE do_not_render=true` still scored `live_count=1 verdict=go`
587
+ for any non-shipped pick, so naively following the Step-1 outcome table
588
+ launched an Opus subprocess against a WEDGEd (often body-empty) packet
589
+ ([[feedback_fanout_preflight_blind_to_verdict_envelope]]). Returns the
590
+ parsed dict, or None if the file is absent / unreadable / not an object.
591
+ """
592
+ path = _next_up_dir() / f".verdict-{tag}.json"
593
+ try:
594
+ if not path.exists():
595
+ return None
596
+ data = json.loads(path.read_text(encoding="utf-8"))
597
+ except (OSError, json.JSONDecodeError):
598
+ return None
599
+ return data if isinstance(data, dict) else None
600
+
601
+
602
+ def _envelope_refusal(envelope: dict | None) -> tuple[bool, str | None]:
603
+ """Decide whether a verdict envelope means REFUSE (do not launch).
604
+
605
+ Thin wrapper over the canonical `wedge_reason.envelope_is_refusal` (the one
606
+ place the envelope-refusal shape is defined, shared with `decisions`); kept as a
607
+ named local so this module's callers read naturally. See that function for the
608
+ rung order.
609
+ """
610
+ from dos import wedge_reason # noqa: PLC0415
611
+ return wedge_reason.envelope_is_refusal(envelope)
612
+
613
+
614
+ def _body_empty_picks(picks: list[dict]) -> list[int]:
615
+ """Pick-numbers whose body is empty (prompt_text_len 0 AND no files).
616
+
617
+ A body-empty pick (`prompt_text_len==0` and `files==[]`) is a refuse signal
618
+ independent of the verdict envelope — it is a scope-substituted / mis-rendered
619
+ pick with nothing for the subagent to do
620
+ ([[feedback_fanout_preflight_blind_to_verdict_envelope]]). Reported so the
621
+ SKILL can refuse rather than launch a no-op Opus subprocess.
622
+ """
623
+ out: list[int] = []
624
+ for p in picks:
625
+ text_len = p.get("prompt_text_len")
626
+ files = p.get("files") or []
627
+ if (text_len == 0 or text_len is None) and not files and p.get("verdict") == "go":
628
+ n = p.get("n")
629
+ if isinstance(n, int):
630
+ out.append(n)
631
+ return out
632
+
633
+
634
+ def _sidecar_dropped_refusal(
635
+ sidecar_status: str, rendered_pick_count: int
636
+ ) -> tuple[bool, str | None]:
637
+ """Decide whether a missing/corrupt `.prompts.json` sidecar means REFUSE.
638
+
639
+ FQ-420: when `/next-up` returns a packet that HAS picks but drops the prompt
640
+ sidecar, the markdown fallback rehydrates the picks with empty bodies, so
641
+ every `/fanout` refuses on `body_empty_picks` — but that names the symptom,
642
+ not the cause. This is the ROOT signal: a packet that rendered >= 1 pick but
643
+ whose sidecar is `absent` (renderer never wrote it) or `corrupt` (wrote a
644
+ broken one) is a renderer defect that blocks the whole dispatch path. Refuse
645
+ with a reason that points at the dropped sidecar so the operator (and the
646
+ `/unstick` cue → `BlockedReason.BODY_EMPTY_PICKS`) routes the fix at the
647
+ renderer, not the picks.
648
+
649
+ Does NOT refuse when:
650
+ * the sidecar was present (`SIDECAR_PRESENT`) — the normal path; or
651
+ * the packet rendered NO picks (`rendered_pick_count == 0`) — a genuine
652
+ empty DRAIN packet legitimately has no sidecar, and refusing it here
653
+ would mislabel a true drain as a renderer drop.
654
+
655
+ Returns `(refuse, reason)`; `reason` is a short machine-readable string in
656
+ the same shape as `_envelope_refusal`'s.
657
+ """
658
+ if rendered_pick_count <= 0:
659
+ return (False, None)
660
+ if sidecar_status == SIDECAR_ABSENT:
661
+ return (
662
+ True,
663
+ f"sidecar_dropped:absent rendered_picks={rendered_pick_count} "
664
+ f"(/next-up returned picks but never wrote the .prompts.json prompt "
665
+ f"sidecar — every pick body is empty)",
666
+ )
667
+ if sidecar_status == SIDECAR_CORRUPT:
668
+ return (
669
+ True,
670
+ f"sidecar_dropped:corrupt rendered_picks={rendered_pick_count} "
671
+ f"(.prompts.json exists but is unreadable/bad-JSON — prompt bodies lost)",
672
+ )
673
+ return (False, None)
674
+
675
+
676
+ def build_context(packet_path: Path) -> dict:
677
+ sidecar = load_packet_sidecar(packet_path)
678
+ pick_phase_keys = {
679
+ f"{p.get('plan_id','')}/{p.get('phase_id','')}" for p in sidecar["picks"]
680
+ }
681
+ # Also build a phase-only set for the in-flight phase-id check (the
682
+ # registry's phase ids are unambiguous within a plan).
683
+ pick_phases = {p.get("phase_id", "") for p in sidecar["picks"]}
684
+ freshness = packet_freshness(packet_path)
685
+ shipped = packet_shipped_verdict(packet_path)
686
+ own_packet_basename = packet_path.stem
687
+ in_flight_rows, in_flight_overlap = list_active_filtered(
688
+ pick_phase_keys | pick_phases, own_packet_basename=own_packet_basename
689
+ )
690
+ picks_out, drops = merge_picks_with_verdicts(
691
+ sidecar["picks"], shipped, in_flight_overlap
692
+ )
693
+ waves = partition_waves(picks_out)
694
+
695
+ # FQ-410: read the verdict envelope for this packet's tag (the packet stem)
696
+ # and decide refusal. A WEDGE/DRAIN/do_not_render envelope means the lane was
697
+ # already routed to /replan — the orchestrator must NOT launch regardless of
698
+ # how many picks look live. A body-empty go-pick is a second, independent
699
+ # refuse signal. `refuse` is the single load-bearing bool the SKILL branches
700
+ # on; `refuse_reasons` lists each contributing cause for the operator log.
701
+ tag = packet_path.stem
702
+ verdict_envelope = read_verdict_envelope(tag)
703
+ env_refuse, env_reason = _envelope_refusal(verdict_envelope)
704
+ body_empty = _body_empty_picks(picks_out)
705
+ # FQ-420: a dropped/corrupt prompt sidecar on a packet that rendered picks is
706
+ # the ROOT refuse signal — listed BEFORE body_empty_picks so the operator
707
+ # reads the cause (sidecar gone) above the symptom (empty bodies). Keyed on
708
+ # the count of picks the packet RENDERED (len(sidecar["picks"])), not the
709
+ # merged go-count, so a packet that claimed work but lost its bodies still
710
+ # trips it even if every pick later reads as a drop.
711
+ sidecar_refuse, sidecar_reason = _sidecar_dropped_refusal(
712
+ sidecar.get("sidecar_status", SIDECAR_ABSENT), len(sidecar["picks"])
713
+ )
714
+ refuse_reasons: list[str] = []
715
+ if sidecar_refuse and sidecar_reason:
716
+ refuse_reasons.append(sidecar_reason)
717
+ if env_refuse and env_reason:
718
+ refuse_reasons.append(env_reason)
719
+ if body_empty:
720
+ refuse_reasons.append(
721
+ "body_empty_picks=" + ",".join(str(n) for n in body_empty)
722
+ )
723
+ return {
724
+ "schema_version": SCHEMA_VERSION,
725
+ "generated_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
726
+ "packet": {
727
+ "path": freshness["path"],
728
+ "last_sha": freshness["last_sha"],
729
+ "drift_count": freshness["drift_count"],
730
+ "drift_commits": freshness["drift_commits"],
731
+ "schema": sidecar["schema"],
732
+ "source": sidecar["source"],
733
+ "sidecar_path": sidecar["sidecar_path"],
734
+ # FQ-420 — the prompt-sidecar state (present/absent/corrupt). A
735
+ # non-`present` status on a packet with picks is the dropped-sidecar
736
+ # root cause behind a body_empty_picks refuse.
737
+ "sidecar_status": sidecar.get("sidecar_status", SIDECAR_ABSENT),
738
+ # OC4 handoff-contract check (distinct from `schema`, which is the
739
+ # *prompts sidecar* schema). `packet_schema` is the versioned token
740
+ # in the packet markdown header; `schema_drift` is the load-bearing
741
+ # gate the orchestrator branches on at Step 1. `.get` with a
742
+ # conservative default so a patched/legacy `packet_freshness` (e.g.
743
+ # an older test stub) still produces a valid bundle — absent keys
744
+ # default to "treat as drift" (the safe, non-launching outcome).
745
+ "packet_schema": freshness.get("packet_schema"),
746
+ "expected_packet_schema": freshness.get(
747
+ "expected_packet_schema", EXPECTED_PACKET_SCHEMA
748
+ ),
749
+ "schema_drift": freshness.get("schema_drift", True),
750
+ "schema_drift_reason": freshness.get("schema_drift_reason"),
751
+ },
752
+ "picks": picks_out,
753
+ "waves": waves,
754
+ "drop_list": drops,
755
+ "live_count": sum(1 for p in picks_out if p.get("verdict") == "go"),
756
+ # FQ-410 — the verdict-envelope refusal gate. `refuse=True` means DO NOT
757
+ # LAUNCH even if live_count>0: the lane was pre-routed WEDGE/DRAIN, or a
758
+ # go-pick is body-empty. The orchestrator's Step-1 outcome table must
759
+ # check `refuse` BEFORE acting on `live_count`.
760
+ "refuse": bool(refuse_reasons),
761
+ "refuse_reasons": refuse_reasons,
762
+ "verdict_envelope": (
763
+ {
764
+ "present": verdict_envelope is not None,
765
+ "verdict": (verdict_envelope or {}).get("verdict"),
766
+ "reason_class": (verdict_envelope or {}).get("reason_class"),
767
+ "do_not_render": (verdict_envelope or {}).get("do_not_render"),
768
+ "blocked": (verdict_envelope or {}).get("blocked"),
769
+ "all_clear": (verdict_envelope or {}).get("all_clear"),
770
+ }
771
+ if verdict_envelope is not None
772
+ else {"present": False}
773
+ ),
774
+ "body_empty_picks": body_empty,
775
+ "shipped_check_exit": shipped["exit_code"],
776
+ "in_flight_overlap_phases": in_flight_overlap,
777
+ "in_flight_rows": in_flight_rows,
778
+ "dirty_tree": dirty_tree_state(),
779
+ "archive_lock": archive_lock_state(),
780
+ # Operator-mutable dispatch flags relevant to the grandchild launch
781
+ # (model for `fanout.child`, lane-leasing/focus-auto state). The
782
+ # SKILL.md grandchild-launch step reads `feature_flags.fanout_child_model`
783
+ # for `--model` rather than hardcoding it.
784
+ "feature_flags": _feature_flags_view(),
785
+ }
786
+
787
+
788
+ def main(argv: list[str] | None = None) -> int:
789
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
790
+ ap.add_argument("packet_path", help="Path to the /next-up packet markdown")
791
+ ap.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
792
+ args = ap.parse_args(argv)
793
+
794
+ packet = Path(args.packet_path)
795
+ if not packet.is_absolute():
796
+ packet = (_workspace_root() / packet).resolve()
797
+ if not packet.exists():
798
+ print(json.dumps({
799
+ "error": "packet-not-found",
800
+ "path": str(packet),
801
+ }), file=sys.stderr)
802
+ return 2
803
+
804
+ try:
805
+ ctx = build_context(packet)
806
+ except Exception as e: # pragma: no cover
807
+ print(json.dumps({
808
+ "error": f"build-failed: {type(e).__name__}: {e}",
809
+ }), file=sys.stderr)
810
+ return 1
811
+
812
+ # Ensure UTF-8 on Windows where stdout defaults to cp1252.
813
+ try:
814
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
815
+ except Exception:
816
+ pass
817
+ if args.pretty:
818
+ print(json.dumps(ctx, indent=2, ensure_ascii=False))
819
+ else:
820
+ print(json.dumps(ctx, ensure_ascii=False))
821
+ return 0
822
+
823
+
824
+ if __name__ == "__main__":
825
+ sys.exit(main())