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/run_id.py ADDED
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env python3
2
+ """The single source of truth for what a runtime *run-id* is (CID-series).
3
+
4
+ docs/64_correlation-id-spine-plan.md — CID1 (the throughline slice).
5
+
6
+ The reference userland app is saturated with IDs at the *plan/phase* altitude
7
+ (~90 series prefixes, ~230 baseline dirs, the FQ-NNN findings queue). The thin spot is
8
+ the **runtime**: a `/dispatch` / `/fanout` / `/dispatch-loop` iteration is
9
+ identified only by its UTC directory name (`docs/_fanout_runs/20260531T143451Z/`),
10
+ a string that
11
+
12
+ - is NOT collision-safe across concurrent same-host loops (two loops can
13
+ mint the same second — the recurring WinError5 / torn-write race), and
14
+ - carries NO lineage (the dispatch → next-up → fanout → N×`claude -p` tree
15
+ is reconstructed by timestamp-correlation + git-log grep, not a join).
16
+
17
+ A `RunId` fixes both without losing the one good property the bare timestamp
18
+ has — sortability:
19
+
20
+ RID-<base32-ts-ms><sep><base32-entropy>
21
+ └ Crockford base32 of epoch-ms (sortable) └ (pid, monotonic_ns) tail
22
+
23
+ Lexicographic sort on the token == chronological order, so it drops straight
24
+ into the existing timestamp-named dirs. The entropy tail (derived from
25
+ `(pid, monotonic_ns)`, the same collision-safe idiom the reference userland app
26
+ uses for its stable event ids) makes two ids minted in the same
27
+ millisecond distinct.
28
+
29
+ DESIGN RULES (docs/64):
30
+ - This module adds NO new series prefix. It mints ONE id *kind* (`run_id`)
31
+ and carries lineage in three explicit fields (run_id / parent_id / root_id).
32
+ - The clock and entropy source are **injectable** so tests are deterministic
33
+ (the reference userland app bans non-deterministic time in reproducible
34
+ paths for exactly this reason). Production callers use the module defaults.
35
+ - Telemetry never blocks: callers wrap mint() failures and degrade to the
36
+ bare timestamp. mint() itself never raises on normal input.
37
+
38
+ The minted `RunId.run_id` is shaped to drop directly into the reference userland
39
+ app's run-context id field (currently a bare uuid4), so the
40
+ event spine — whose `compute_event_id(run_id, ...)` already makes run_id its
41
+ first component — lights up the moment a run sets the context.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import itertools
48
+ import json
49
+ import os
50
+ import sys
51
+ import threading
52
+ import time
53
+ from dataclasses import dataclass
54
+ from pathlib import Path
55
+ from typing import Callable
56
+
57
+ from dos import _filelock
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Encoding — Crockford base32 (no I/L/O/U; case-insensitive; sortable).
61
+ # We keep our own tiny encoder rather than pull a dep; the alphabet is ordered
62
+ # so that lexicographic compare on the encoded string matches numeric order.
63
+ # ---------------------------------------------------------------------------
64
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" # 32 symbols, ascending
65
+
66
+
67
+ def _b32(n: int, *, width: int) -> str:
68
+ """Left-zero-padded Crockford base32 of a non-negative int.
69
+
70
+ Fixed ``width`` keeps every id the same length so lexicographic sort over a
71
+ batch of ids is total (a shorter encoding would sort before a longer one
72
+ regardless of value).
73
+ """
74
+ if n < 0:
75
+ raise ValueError("run-id components must be non-negative")
76
+ out = []
77
+ for _ in range(width):
78
+ out.append(_CROCKFORD[n & 0x1F])
79
+ n >>= 5
80
+ return "".join(reversed(out))
81
+
82
+
83
+ # Crockford base32 carries 5 bits/symbol. Current epoch-ms (~1.78e12, May 2026)
84
+ # is ~41 bits, so 8 symbols (40 bits, max ~1.10e12 ms ≈ year 2004) WRAP — the
85
+ # high bit is lost and sortability breaks past that boundary. 9 symbols carry
86
+ # 45 bits (max ~3.52e13 ms ≈ year 3084), which covers epoch-ms with headroom.
87
+ # (Regression pinned by test_minted_token_validates_and_decodes.)
88
+ _TS_WIDTH = 9
89
+ # 30 bits of entropy → 6 symbols. Plenty to separate same-ms mints on one host.
90
+ _ENTROPY_WIDTH = 6
91
+ _ENTROPY_BITS = _ENTROPY_WIDTH * 5 # 30
92
+
93
+ PREFIX = "RID-"
94
+ PROCESS_PREFIX = "PROC-"
95
+
96
+
97
+ def _default_clock_ms() -> int:
98
+ """Wall-clock epoch-ms. Injectable so tests pin a fixed instant."""
99
+ return int(time.time() * 1000)
100
+
101
+
102
+ # A strictly-increasing in-process counter. monotonic_ns() ALONE is not enough:
103
+ # on Windows its resolution is coarse (~15 ms), so a rapid mint batch reads the
104
+ # SAME ns for thousands of calls — folding that to 30 bits then collapses the
105
+ # batch to a handful of distinct ids (the observed 12/5000 collision-safety
106
+ # failure). A per-process counter is monotonic regardless of clock resolution,
107
+ # so consecutive same-host mints are ALWAYS distinct. Lock-guarded so concurrent
108
+ # threads in one process can't read the same counter value.
109
+ _MINT_COUNTER = itertools.count()
110
+ _MINT_COUNTER_LOCK = threading.Lock()
111
+
112
+
113
+ def _next_mint_seq() -> int:
114
+ with _MINT_COUNTER_LOCK:
115
+ return next(_MINT_COUNTER)
116
+
117
+
118
+ def _default_entropy() -> int:
119
+ """Per-mint entropy from ``(pid, in-process counter, monotonic_ns)`` — an
120
+ extension of the collision-safe idiom `_stable_event_id` uses, hardened for
121
+ coarse-resolution clocks. The in-process counter occupies the LOW bits so it
122
+ survives the ``_ENTROPY_BITS`` fold (it is the part that guarantees two mints
123
+ in the same wall-clock millisecond — even the same monotonic_ns tick —
124
+ differ); pid + monotonic_ns fill the high bits to separate concurrent
125
+ processes on one host and add wall-time variation. Folded to ``_ENTROPY_BITS``.
126
+ """
127
+ seq = _next_mint_seq()
128
+ # Counter in the low bits (survives the fold and is the distinctness floor);
129
+ # pid + monotonic_ns xored into the high bits for cross-process separation.
130
+ raw = seq ^ ((os.getpid() << 13) ^ (time.monotonic_ns() << 7))
131
+ return raw & ((1 << _ENTROPY_BITS) - 1)
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class RunId:
136
+ """A minted run-id plus its lineage and the process it belongs to.
137
+
138
+ ``run_id`` — this invocation's own sortable, collision-safe token.
139
+ ``process_id`` — the repeatable-process slug (PROC-…), declared not minted;
140
+ lets "the same process across invocations" be a query.
141
+ ``parent_id`` — the run_id that launched this one (None for a root).
142
+ ``root_id`` — top of the tree (== run_id for a root; inherited otherwise).
143
+ ``ts_ms`` — the epoch-ms encoded in run_id, kept for cheap reads.
144
+ """
145
+
146
+ run_id: str
147
+ process_id: str
148
+ parent_id: str | None
149
+ root_id: str
150
+ ts_ms: int
151
+
152
+ def to_dict(self) -> dict:
153
+ """The exact shape written to a run-dir's ``run.json`` (CID1)."""
154
+ return {
155
+ "run_id": self.run_id,
156
+ "process_id": self.process_id,
157
+ "parent_id": self.parent_id,
158
+ "root_id": self.root_id,
159
+ "ts_ms": self.ts_ms,
160
+ }
161
+
162
+
163
+ def mint(
164
+ process_id: str,
165
+ *,
166
+ parent: "RunId | str | None" = None,
167
+ root_id: str | None = None,
168
+ clock_ms: Callable[[], int] = _default_clock_ms,
169
+ entropy: Callable[[], int] = _default_entropy,
170
+ ) -> RunId:
171
+ """Mint a fresh ``RunId`` for one invocation of ``process_id``.
172
+
173
+ Lineage: pass ``parent`` (a RunId or its run_id string) for a child; the
174
+ child inherits the parent's ``root_id`` and sets ``parent_id`` to the
175
+ parent's run_id. A root (operator-initiated) passes no parent and becomes
176
+ its own root. ``root_id`` may be passed explicitly when only the string is
177
+ known (e.g. inherited from an env var across a `claude -p` boundary).
178
+
179
+ ``clock_ms`` / ``entropy`` are injected in tests for determinism.
180
+ """
181
+ if not process_id:
182
+ raise ValueError("process_id is required (e.g. 'fanout', 'dispatch-loop')")
183
+ proc = process_id if process_id.startswith(PROCESS_PREFIX) else PROCESS_PREFIX + process_id
184
+
185
+ ts_ms = int(clock_ms())
186
+ token = PREFIX + _b32(ts_ms, width=_TS_WIDTH) + _b32(entropy() & ((1 << _ENTROPY_BITS) - 1), width=_ENTROPY_WIDTH)
187
+
188
+ parent_id: str | None
189
+ if parent is None:
190
+ parent_id = None
191
+ elif isinstance(parent, RunId):
192
+ parent_id = parent.run_id
193
+ root_id = root_id or parent.root_id
194
+ else:
195
+ parent_id = str(parent)
196
+
197
+ resolved_root = root_id or token # a root is its own root
198
+ return RunId(
199
+ run_id=token,
200
+ process_id=proc,
201
+ parent_id=parent_id,
202
+ root_id=resolved_root,
203
+ ts_ms=ts_ms,
204
+ )
205
+
206
+
207
+ def is_run_id(s: str) -> bool:
208
+ """True iff ``s`` is a structurally-valid minted run-id token."""
209
+ if not isinstance(s, str) or not s.startswith(PREFIX):
210
+ return False
211
+ body = s[len(PREFIX):]
212
+ if len(body) != _TS_WIDTH + _ENTROPY_WIDTH:
213
+ return False
214
+ return all(c in _CROCKFORD for c in body)
215
+
216
+
217
+ def ts_ms_of(run_id: str) -> int | None:
218
+ """Decode the epoch-ms a run-id encodes (None if not a valid token)."""
219
+ if not is_run_id(run_id):
220
+ return None
221
+ ts_part = run_id[len(PREFIX):len(PREFIX) + _TS_WIDTH]
222
+ n = 0
223
+ for c in ts_part:
224
+ n = (n << 5) | _CROCKFORD.index(c)
225
+ return n
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Lineage transport across a `claude -p` boundary (CID2/CID3 will wire this in;
230
+ # defined here so the contract lives next to the minter, not scattered).
231
+ # ---------------------------------------------------------------------------
232
+ ENV_RUN_ID = "CID_RUN_ID"
233
+ ENV_PARENT_ID = "CID_PARENT_ID"
234
+ ENV_ROOT_ID = "CID_ROOT_ID"
235
+ ENV_PROCESS_ID = "CID_PROCESS_ID"
236
+
237
+
238
+ def lineage_env(run: RunId) -> dict[str, str]:
239
+ """The env block a parent sets so a child subprocess can inherit lineage."""
240
+ env = {ENV_RUN_ID: run.run_id, ENV_ROOT_ID: run.root_id, ENV_PROCESS_ID: run.process_id}
241
+ if run.parent_id:
242
+ env[ENV_PARENT_ID] = run.parent_id
243
+ return env
244
+
245
+
246
+ def mint_child_from_env(
247
+ process_id: str,
248
+ *,
249
+ env: dict[str, str] | None = None,
250
+ clock_ms: Callable[[], int] = _default_clock_ms,
251
+ entropy: Callable[[], int] = _default_entropy,
252
+ ) -> RunId:
253
+ """Mint a child run-id inheriting lineage from ``CID_*`` env vars.
254
+
255
+ If no parent env is present (an operator-initiated root), this is a root
256
+ mint. The parent's run_id becomes this child's ``parent_id``; the root is
257
+ inherited from ``CID_ROOT_ID`` (falling back to the parent run_id, then to
258
+ self for a root).
259
+ """
260
+ e = env if env is not None else dict(os.environ)
261
+ parent = e.get(ENV_RUN_ID)
262
+ root = e.get(ENV_ROOT_ID) or parent
263
+ return mint(
264
+ process_id,
265
+ parent=parent,
266
+ root_id=root,
267
+ clock_ms=clock_ms,
268
+ entropy=entropy,
269
+ )
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # Run-dir read-back — the CID1 query path. Resolve a run-dir → its run.json.
274
+ # ---------------------------------------------------------------------------
275
+ RUN_JSON_NAME = "run.json"
276
+
277
+
278
+ def write_run_json(run_dir: Path, run: RunId) -> Path:
279
+ """Stamp ``run.json`` into a run-dir. Returns the path written.
280
+
281
+ Never raises on a telemetry-only failure path the way callers expect — the
282
+ caller wraps this; here we just do the atomic-ish write.
283
+ """
284
+ run_dir.mkdir(parents=True, exist_ok=True)
285
+ target = run_dir / RUN_JSON_NAME
286
+ tmp = run_dir / (RUN_JSON_NAME + ".tmp")
287
+ tmp.write_text(json.dumps(run.to_dict(), indent=2, sort_keys=True), encoding="utf-8")
288
+ _filelock.atomic_replace(tmp, target) # atomic on same fs; win32 rename-race hardened
289
+ return target
290
+
291
+
292
+ def read_run_json(run_dir: Path) -> dict | None:
293
+ """Read a run-dir's ``run.json`` (None if absent / unreadable)."""
294
+ target = Path(run_dir) / RUN_JSON_NAME
295
+ if not target.exists():
296
+ return None
297
+ try:
298
+ return json.loads(target.read_text(encoding="utf-8"))
299
+ except Exception: # noqa: BLE001 — a corrupt stamp must not crash a read
300
+ return None
301
+
302
+
303
+ def _cmd_mint(args: argparse.Namespace) -> int:
304
+ run = mint(args.process, parent=args.parent, root_id=args.root)
305
+ print(json.dumps(run.to_dict(), indent=2, sort_keys=True))
306
+ if args.write_dir:
307
+ path = write_run_json(Path(args.write_dir), run)
308
+ print(f"# wrote {path}", file=sys.stderr)
309
+ return 0
310
+
311
+
312
+ def _cmd_show(args: argparse.Namespace) -> int:
313
+ """Resolve a run-dir → its run-id + lineage (the CID1 read-back)."""
314
+ data = read_run_json(Path(args.dir))
315
+ if data is None:
316
+ print(f"no {RUN_JSON_NAME} in {args.dir}", file=sys.stderr)
317
+ return 1
318
+ print(json.dumps(data, indent=2, sort_keys=True))
319
+ return 0
320
+
321
+
322
+ def main(argv: list[str] | None = None) -> int:
323
+ p = argparse.ArgumentParser(description="Mint / inspect runtime run-ids (CID-series; docs/64).")
324
+ sub = p.add_subparsers(dest="cmd", required=True)
325
+
326
+ m = sub.add_parser("mint", help="mint a run-id (optionally stamp run.json into a dir)")
327
+ m.add_argument("process", help="process slug, e.g. 'fanout' / 'dispatch-loop'")
328
+ m.add_argument("--parent", default=None, help="parent run_id (for a child mint)")
329
+ m.add_argument("--root", default=None, help="root run_id (inherited across a subprocess boundary)")
330
+ m.add_argument("--write-dir", default=None, help="run-dir to stamp run.json into")
331
+ m.set_defaults(func=_cmd_mint)
332
+
333
+ s = sub.add_parser("show", help="resolve a run-dir → its run-id + lineage")
334
+ s.add_argument("dir", help="a run-dir (e.g. docs/_fanout_runs/<ts>/)")
335
+ s.set_defaults(func=_cmd_show)
336
+
337
+ args = p.parse_args(argv)
338
+ return args.func(args)
339
+
340
+
341
+ if __name__ == "__main__":
342
+ raise SystemExit(main())