froid-loop 0.11.1__py3-none-any.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 (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
@@ -0,0 +1,532 @@
1
+ """The library-level read-model projection layer for the ``--json`` contract.
2
+
3
+ Domain object in, contract document dict out. Every builder here is a pure
4
+ projection — it reads an already-loaded domain object (a ``ValidationReport``, a
5
+ ``RunState``, a list of ``Decision`` / ``RunInfo``) and returns the plain dict
6
+ that :mod:`froid_loop.machine` serializes. No I/O, no process state, no printing,
7
+ no exit codes: the caller loads, this layer projects, ``machine.emit`` writes.
8
+
9
+ Each command owns its own ``*_SCHEMA_VERSION`` constant and obeys the pure-document
10
+ contract documented in :mod:`froid_loop.machine` — one JSON object, additive-only
11
+ evolution, anything breaking bumps that command's version. Those constants live
12
+ here, next to the builders they version, because a field and its version bump are
13
+ one edit.
14
+
15
+ This mirrors the split :mod:`froid_loop.probe` and :mod:`froid_loop.diagnostics`
16
+ already make — one finding, two render targets — generalized to the commands whose
17
+ document is a dict rather than a rendered string. The point of the separation is
18
+ that the contract is not a CLI feature: a future non-CLI frontend (the planned web
19
+ backend) imports these builders directly and serializes them itself, never
20
+ shelling out to the CLI to parse its stdout. Keeping them in ``cli.py`` made the
21
+ library surface reachable only through ``argparse``, and made every new command's
22
+ document another few hundred lines of accretion in the dispatch module.
23
+
24
+ So: add a new command's builder here, not in ``cli.py``. ``cli.py`` re-imports
25
+ these names, which is what keeps ``cli.STATUS_SCHEMA_VERSION`` and friends
26
+ resolving for existing callers and tests.
27
+
28
+ Every name in this module is public, and a new builder must be too: the
29
+ projection surface *is* the API, so a leading underscore would say "do not
30
+ import this" about the one thing the module exists to be imported for. The
31
+ builders carried one until #212 only because they were born private inside
32
+ ``cli.py``. ``run_token_totals`` is public for the same reason even though it
33
+ projects no document of its own — ``cmd_status`` calls it across the module
34
+ boundary to render text, and a private name imported by another module is the
35
+ contradiction this module is meant not to have. Keep genuinely intra-module
36
+ helpers private, as :mod:`froid_loop.probe` and :mod:`froid_loop.diagnostics` do.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from typing import TYPE_CHECKING
42
+
43
+ from . import policy, runs
44
+
45
+ if TYPE_CHECKING:
46
+ from .checks import ValidationReport
47
+ from .model import RunState
48
+ from .operatoractions import ParkedStory
49
+ from .runs import RunInfo
50
+ from .sweep import Decision
51
+
52
+
53
+ VALIDATE_SCHEMA_VERSION = 1
54
+
55
+
56
+ def validate_document(
57
+ report: ValidationReport, stories_on: bool, spec_folder: str
58
+ ) -> dict[str, object]:
59
+ """The `validate --json` document: the verdict plus every check that produced it.
60
+
61
+ Obeys the pure-document contract in machine.py (additive-only evolution;
62
+ anything breaking bumps VALIDATE_SCHEMA_VERSION). ``ok`` is true when no
63
+ finding has severity ``problem`` — warnings do not clear it — so it mirrors
64
+ the exit code exactly. This is the first ``--json`` command that emits a whole
65
+ document at rc 1: the nonzero code is the verdict being reported, not a
66
+ failure to produce one (see machine.py on parsing non-empty stdout whatever
67
+ the exit code).
68
+
69
+ Four things a consumer has to know:
70
+
71
+ - **``message`` is not contracted.** Several problems are a bare ``str(e)``
72
+ from the config, policy, profile and sprint-status exceptions, so their
73
+ wording moves with those modules. ``check`` is the only matchable identity;
74
+ match on it, and read ``message``/``detail`` for humans.
75
+ - **Absence is not a pass.** The gates are chained: if policy fails to load,
76
+ ``profiles`` is empty and the binary, hook and base-skill gates contribute
77
+ no finding at all. A check id missing from ``findings`` means "did not run",
78
+ never "passed" — check ``ok`` for the verdict, not the absence of an id.
79
+ - **``mux.backends-detected`` is gated on more than one registered backend**,
80
+ so a lone-tmux host carries no backend inventory. Same rule as above. The one
81
+ exception to its ``ok`` severity is a detection failure, which reports under
82
+ the same id at ``warning`` and carries no ``detail`` at all — read the
83
+ severity, never index ``detail["backends"]`` without a null check.
84
+ - **``mux.selection`` is not gated on the reason.** It used to appear only for a
85
+ forced ``env``/``policy`` choice and now names the reason wherever selection
86
+ resolves. It is absent whenever no backend row is *selected*, which has three
87
+ causes: a forced name matching no registered backend (``mux.preflight``
88
+ carries that failure), a detection failure (the ``warning`` above carries
89
+ it), and ``_select`` bottoming out at its historical tmux fallback with tmux
90
+ unregistered (nothing else reports that — the inventory simply holds no
91
+ selected row). Same rule as above.
92
+
93
+ ``findings`` stays flat and in emission order rather than grouped by severity:
94
+ grouping would destroy the cross-severity ordering (the order the gates ran)
95
+ and turn "every non-ok finding" into a two-array concatenation.
96
+ """
97
+ return {
98
+ "schema_version": VALIDATE_SCHEMA_VERSION,
99
+ "ok": report.passed,
100
+ "mode": "stories" if stories_on else "sprint",
101
+ # "" rather than null in sprint mode, where a spec folder is inapplicable —
102
+ # the same convention as list_document's paused_stage.
103
+ "spec_folder": spec_folder if stories_on else "",
104
+ "counts": report.counts(),
105
+ "findings": [
106
+ {
107
+ "check": f.check,
108
+ "severity": f.severity,
109
+ "message": f.message,
110
+ "detail": f.detail,
111
+ }
112
+ for f in report.findings
113
+ ],
114
+ }
115
+
116
+
117
+ CONFIRM_SCHEMA_VERSION = 1
118
+
119
+
120
+ def confirm_document(parked: list[ParkedStory]) -> dict[str, object]:
121
+ """The `confirm --list --json` document: every story parked at
122
+ `awaiting-operator`, in story-key order, with what each owes.
123
+
124
+ Obeys the pure-document contract in machine.py (additive-only evolution;
125
+ anything breaking bumps CONFIRM_SCHEMA_VERSION). Nothing parked is a valid
126
+ empty document with exit 0, never an error.
127
+
128
+ Carries BOTH `confirmable` and the raw `spec_status`/`board_status` it is
129
+ derived from. A consumer scripting confirmations only needs the boolean, but
130
+ one triaging a stuck board needs to see which side disagreed — collapsing
131
+ them would make the document say a story "cannot be confirmed" without ever
132
+ saying what is wrong with it, and `drift` is prose, not something to branch
133
+ on. `actions` is the SPEC's list where the spec is readable, matching what
134
+ the text listing shows and what a human would be acknowledging.
135
+
136
+ `resumable` marks a confirmation interrupted between its spec writes and its
137
+ board write — a story `confirm` finishes rather than refuses, even though
138
+ `confirmable` is False and `drift` reads like ordinary staleness. It ships
139
+ with the raw `confirmation_recorded` it is derived from, for the same reason
140
+ `confirmable` ships with the two statuses: a consumer triaging a stuck board
141
+ has to be able to see WHICH reading made the boolean what it is.
142
+
143
+ CONFIRM_SCHEMA_VERSION deliberately stays 1. Evolution here is additive-only
144
+ (machine.py), new fields may appear, and no field has changed presence or
145
+ type. One value semantic did move with #356, stated honestly: `commit` is now
146
+ DERIVED from the park record's git history rather than stored at park time
147
+ (the committed record cannot contain its own sha), so it can be `""` for a
148
+ record not yet in any commit — a consumer that pinned "always 40 hex chars"
149
+ must treat empty as "no provenance", which the field always could express."""
150
+ return {
151
+ "schema_version": CONFIRM_SCHEMA_VERSION,
152
+ "parked": [
153
+ {
154
+ "story_key": p.story_key,
155
+ "actions": list(p.actions),
156
+ "spec_file": str(p.spec_path) if p.spec_path is not None else None,
157
+ "spec_status": p.spec_status,
158
+ "board_status": p.board_status,
159
+ "commit": p.commit,
160
+ "run_id": p.run_id,
161
+ "parked_at": p.parked_at,
162
+ "confirmable": p.confirmable,
163
+ "confirmation_recorded": p.confirmation_recorded,
164
+ "resumable": p.resumable,
165
+ "drift": p.drift(),
166
+ }
167
+ for p in parked
168
+ ],
169
+ }
170
+
171
+
172
+ DECISIONS_SCHEMA_VERSION = 1
173
+
174
+
175
+ def decisions_document(pending: list[Decision]) -> dict[str, object]:
176
+ """The `decisions --json` document: every pending decision, in DW order.
177
+
178
+ Obeys the pure-document contract in machine.py (additive-only evolution;
179
+ anything breaking bumps DECISIONS_SCHEMA_VERSION). A pure projection of
180
+ pending_missed_decisions(), and a lossless one — unlike the `--list` text,
181
+ which drops `context` outright and shows only key/label/effect of each
182
+ option, hiding the `intent`/`resolution`/`bundle_name` that decide what a
183
+ sweep actually builds or writes. A caller answering by policy needs those,
184
+ so the document carries the whole dataclass.
185
+
186
+ `recommended` is the derived form of the decision's `recommendation` key,
187
+ so a consumer never has to cross-reference two fields (the text encodes it
188
+ as a "(recommended)" suffix on a free-text line). Exactly one option
189
+ carries it when the recommendation names a real key. Nothing pending is a
190
+ valid empty document with exit 0, never an error.
191
+ """
192
+ return {
193
+ "schema_version": DECISIONS_SCHEMA_VERSION,
194
+ "decisions": [
195
+ {
196
+ "id": d.id,
197
+ "question": d.question,
198
+ "context": d.context,
199
+ "recommendation": d.recommendation,
200
+ "options": [
201
+ {
202
+ "key": opt.key,
203
+ "label": opt.label,
204
+ "effect": opt.effect,
205
+ "intent": opt.intent,
206
+ "resolution": opt.resolution,
207
+ "bundle_name": opt.bundle_name,
208
+ "recommended": opt.key == d.recommendation,
209
+ }
210
+ for opt in d.options
211
+ ],
212
+ }
213
+ for d in pending
214
+ ],
215
+ }
216
+
217
+
218
+ STATUS_SCHEMA_VERSION = 1
219
+
220
+
221
+ def run_token_totals(state: RunState) -> tuple[int, int, float]:
222
+ """Run-level token totals as ``(raw, weighted, weight)``.
223
+
224
+ The weight is the run's persisted snapshot — never live policy — so the
225
+ figures match what the run actually enforced; the TUI and the run summary
226
+ agree (see Engine.summary). Weighted is the sum of per-task weighted
227
+ totals (sum-of-rounds), never a weighted_total of the summed counters:
228
+ two tasks of 101 cache reads at weight 0.5 weigh 50 + 50 = 100, not
229
+ round(202 * 0.5) = 101.
230
+ """
231
+ weight = state.cache_read_weight()
232
+ raw = sum(t.tokens.total for t in state.tasks.values())
233
+ weighted = sum(t.tokens.weighted_total(weight) for t in state.tasks.values())
234
+ return raw, weighted, weight
235
+
236
+
237
+ def status_document(state: RunState, *, graceful_stop_pending: bool = False) -> dict[str, object]:
238
+ """The `status --json` document: the stable machine-readable contract.
239
+
240
+ Obeys the pure-document contract in machine.py (additive-only evolution;
241
+ anything breaking bumps STATUS_SCHEMA_VERSION), unlike the human-readable
242
+ status text, which is best-effort and free to change. Everything here is
243
+ derived from state.json alone — never from live policy or other project
244
+ files — so a consumer can reproduce the document, and the weight matches
245
+ what the run actually enforced (see run_token_totals). The one exception is
246
+ ``graceful_stop_pending``: liveness plus a *graceful*-mode stop-request
247
+ control file is not in state.json, so the caller supplies it (default False
248
+ keeps the builder a pure projection); ``status`` itself is unaffected. The
249
+ mode read is exact — a lodged hard request (#319) is a stop in flight, not a
250
+ graceful stop pending, and reports False here.
251
+
252
+ Two adapter-identity keys (#153 phase 3), both derived from the snapshot and
253
+ the recorded sessions — never live policy — and deliberately named apart:
254
+
255
+ - Run-level ``adapters`` is the *configured-resolved* identity: the
256
+ dev/review/triage adapter the run's ``policy_snapshot`` resolves to, via
257
+ :func:`policy.adapter_policy_from_snapshot` + ``AdapterPolicy.resolved`` so
258
+ the stage-inheritance rules are the canonical ones, not re-derived here.
259
+ ``None`` when the snapshot carries no rebuildable ``[adapter]`` block (a run
260
+ that predates adapter stamping) — distinct from an all-``claude`` default.
261
+ - Per-task ``adapters_used`` is the *actually-recorded* identity: the last
262
+ stamped :class:`~froid_loop.model.SessionRecord` per role in the task's
263
+ sessions, ``{role: {"name", "model"}}``. ``{}`` for a task whose sessions
264
+ predate stamping (``adapter == ""``). Configured need not equal used — a
265
+ policy edited mid-run, or a role that never ran, diverge.
266
+ """
267
+ raw_total, weighted_total, weight = run_token_totals(state)
268
+ adapter_policy = policy.adapter_policy_from_snapshot(state.policy_snapshot)
269
+ adapters: dict[str, dict[str, str]] | None = None
270
+ if adapter_policy is not None:
271
+ adapters = {}
272
+ for role in ("dev", "review", "triage"):
273
+ resolved = adapter_policy.resolved(role)
274
+ adapters[role] = {"name": resolved.name, "model": resolved.model}
275
+ if state.finished:
276
+ status = "finished"
277
+ elif state.paused:
278
+ status = "paused"
279
+ elif state.crashed:
280
+ status = "crashed"
281
+ elif state.stopped:
282
+ status = "stopped"
283
+ else:
284
+ status = "in-progress"
285
+ tasks: list[dict[str, object]] = []
286
+ for key, task in state.tasks.items():
287
+ tokens = task.tokens.to_dict()
288
+ tokens["raw"] = task.tokens.total
289
+ tokens["weighted"] = task.tokens.weighted_total(weight)
290
+ # Last stamped session per role wins (iteration is chronological); a role
291
+ # whose only sessions predate stamping (adapter "") contributes nothing.
292
+ adapters_used: dict[str, dict[str, str]] = {}
293
+ for record in task.sessions:
294
+ if record.adapter:
295
+ adapters_used[record.role] = {"name": record.adapter, "model": record.model}
296
+ tasks.append(
297
+ {
298
+ "story_key": key,
299
+ "epic": task.epic,
300
+ "phase": str(task.phase),
301
+ "attempt": task.attempt,
302
+ "review_cycle": task.review_cycle,
303
+ "tokens": tokens,
304
+ "commit_sha": task.commit_sha,
305
+ "defer_reason": task.defer_reason,
306
+ # the recovery ref a rolled-back attempt's work was parked on, or
307
+ # null. Reported verbatim from state.json — never re-validated
308
+ # against git here (retention may since have pruned it).
309
+ "preserve_ref": task.preserve_ref,
310
+ "adapters_used": adapters_used,
311
+ }
312
+ )
313
+ return {
314
+ "schema_version": STATUS_SCHEMA_VERSION,
315
+ "run_id": state.run_id,
316
+ "run_type": state.run_type,
317
+ "source": state.source,
318
+ "started_at": state.started_at,
319
+ "status": status,
320
+ "finished": state.finished,
321
+ "stopped": state.stopped,
322
+ "graceful_stop_pending": graceful_stop_pending,
323
+ "crashed": state.crashed,
324
+ "crash_error": state.crash_error,
325
+ "paused_stage": state.paused_stage,
326
+ "paused_reason": state.paused_reason,
327
+ "paused_story_key": state.paused_story_key,
328
+ "cache_read_weight": weight,
329
+ "tokens": {
330
+ "raw": raw_total,
331
+ "weighted": weighted_total,
332
+ },
333
+ "adapters": adapters,
334
+ # auto-sweep triggers the run did not deliver, trigger -> reason slug
335
+ # (model.SWEEP_REFUSED_*). Always present, `{}` when nothing was refused:
336
+ # a key that appears only on the failing run makes "absent" ambiguous
337
+ # between "swept fine" and "old state.json". Additive per machine.py, so
338
+ # STATUS_SCHEMA_VERSION does not move.
339
+ "sweeps_refused": dict(state.sweeps_refused),
340
+ "tasks": tasks,
341
+ }
342
+
343
+
344
+ LIST_SCHEMA_VERSION = 1
345
+
346
+
347
+ def list_document(infos: list[RunInfo]) -> dict[str, object]:
348
+ """The `list --json` document: one entry per run, oldest first.
349
+
350
+ Obeys the pure-document contract in machine.py (additive-only evolution;
351
+ anything breaking bumps LIST_SCHEMA_VERSION). A pure projection of
352
+ discover_runs(): status is its liveness-aware vocabulary
353
+ (running|paused|finished|stopped|crashed|interrupted|unknown), and runs
354
+ whose state.json fails to parse are included (run_type "?", started_at "",
355
+ status "unknown") — enumeration scripts must see them. `ref` is
356
+ runs.short_ref(run_id), derived from the id — stable, not positional.
357
+ paused_stage is "" unless status is "paused". An empty runs dir is a valid
358
+ empty document with exit 0, never an error.
359
+ """
360
+ return {
361
+ "schema_version": LIST_SCHEMA_VERSION,
362
+ "runs": [
363
+ {
364
+ "ref": runs.short_ref(ri.run_id),
365
+ "run_id": ri.run_id,
366
+ "run_type": ri.run_type,
367
+ "started_at": ri.started_at,
368
+ "status": ri.status,
369
+ "paused_stage": ri.paused_stage,
370
+ }
371
+ for ri in infos
372
+ ],
373
+ }
374
+
375
+
376
+ CLEANUP_SCHEMA_VERSION = 2
377
+
378
+
379
+ def cleanup_document(
380
+ *,
381
+ dry_run: bool,
382
+ killed: list[str],
383
+ live: list[str],
384
+ unknown: set[str],
385
+ windows: list[str],
386
+ windows_survived: list[str],
387
+ windows_unverifiable: list[str],
388
+ scan_error: str | None = None,
389
+ legacy_leftovers: list[str] | None = None,
390
+ ) -> dict[str, object]:
391
+ """The `cleanup --json` document: the multiplexer artifacts this invocation
392
+ removed, or — under ``--dry-run`` — would remove.
393
+
394
+ Obeys the pure-document contract in machine.py (additive-only evolution;
395
+ anything breaking bumps CLEANUP_SCHEMA_VERSION). Plan and outcome share one
396
+ shape: the lists mean the same thing either way and `dry_run` alone says
397
+ whether it happened, so a caller can diff a preview against the real run.
398
+ `sessions.removed` holds run ids (the session name is `froid-loop-<id>`),
399
+ `ctl_windows.removed` holds window names. `unverifiable_pid` is the subset
400
+ of `sessions.removed` whose engine liveness could not be proven — the text
401
+ mode's stderr warning, carried in the document so JSON mode leaves stderr
402
+ empty. It never blocks cleanup: pruning kills the tmux session, never the
403
+ engine pid. Nothing to clean up is a valid document of empty lists at
404
+ exit 0, never an error.
405
+
406
+ `ctl_windows` is a three-way partition, disjoint by window id (the values
407
+ are names): `removed` was verified gone after the kill, `survived` was still
408
+ listed, and `unverifiable` is a kill whose outcome could not be probed at
409
+ all. Schema 2 narrowed `removed` from "a kill was attempted" to "the window
410
+ is verifiably gone" (#435) — a meaning change, hence the version bump rather
411
+ than a bare field addition. Under `--dry-run` nothing is killed, so `removed`
412
+ is the would-close plan and the other two are empty — the shared
413
+ plan/outcome shape holds, with `dry_run` still the field that says which one
414
+ you are holding.
415
+
416
+ `ctl_windows.scan_error` is the candidate scan failing before any window was
417
+ chosen or killed: the three arms are empty and mean "no answer", not
418
+ "verified empty". Without it, a failed preflight is indistinguishable from a
419
+ clean scan that found nothing — automation reading the document would accept
420
+ the empty partition and skip cleanup it still owes. The `unverifiable_pid`
421
+ precedent: the degradation travels in the document, not only on stderr.
422
+ `null` when the scan reported no failure — which is as much as this document
423
+ can promise: the seam's listing call degrades a transport fault to an empty
424
+ listing by contract (the sentinel-returner half of the multiplexer seam), so
425
+ that one fault mode still reads as an empty scan. The same documented
426
+ ceiling as prune_ctl_windows' post-kill probe; narrowing it is seam work,
427
+ not a document field.
428
+
429
+ `sessions.legacy_leftovers` is the migration's remainder: session NAMES (not
430
+ run ids — the control session has no run id) that a legacy multiplexer
431
+ registry still holds and that cleanup deliberately did not remove. Additive,
432
+ so no schema bump: a consumer that does not know the key reads exactly what it
433
+ read before. Empty on every platform and every already-migrated machine. See
434
+ `runs.legacy_registry_leftovers` for what qualifies and why; the text mode
435
+ prints the same list on stderr, the `unverifiable_pid` precedent.
436
+
437
+ `sessions.removed` did NOT get the same treatment and is still the pre-kill
438
+ prunable partition — an *attempted* kill, since `kill_session` is best-effort
439
+ and silent in exactly the way `kill_window` is. #435 narrowed the windows
440
+ half only; read the sessions half with that in mind.
441
+ """
442
+ return {
443
+ "schema_version": CLEANUP_SCHEMA_VERSION,
444
+ "dry_run": dry_run,
445
+ "sessions": {
446
+ "removed": list(killed),
447
+ "live": list(live),
448
+ "unverifiable_pid": sorted(unknown),
449
+ "legacy_leftovers": list(legacy_leftovers or []),
450
+ },
451
+ "ctl_windows": {
452
+ "removed": list(windows),
453
+ "survived": list(windows_survived),
454
+ "unverifiable": list(windows_unverifiable),
455
+ "scan_error": scan_error,
456
+ },
457
+ }
458
+
459
+
460
+ CLEAN_SCHEMA_VERSION = 1
461
+
462
+
463
+ def clean_document(
464
+ *,
465
+ dry_run: bool,
466
+ retain: int,
467
+ cleanup_policy: policy.CleanupPolicy,
468
+ freed_bytes: int,
469
+ worktrees: list[str],
470
+ trimmed: list[str],
471
+ archived: list[str],
472
+ deleted: list[str],
473
+ protected: list[str],
474
+ unverifiable_pid: list[str],
475
+ state_dirs_swept: int,
476
+ ) -> dict[str, object]:
477
+ """The `clean --json` document: the disk this invocation reclaimed, or —
478
+ under ``--dry-run`` — would reclaim.
479
+
480
+ Obeys the pure-document contract in machine.py (additive-only evolution;
481
+ anything breaking bumps CLEAN_SCHEMA_VERSION). Plan and outcome share one
482
+ shape: the lists mean the same thing either way and `dry_run` alone says
483
+ whether it happened, so a caller can diff a preview against the real run.
484
+
485
+ `freed_bytes` is a raw integer — the text mode's `~1.2MB` is a rendering of
486
+ this number, and formatting is the renderer's job. It is the same estimate
487
+ the text prints: measured before mutating (so it holds under --dry-run) and
488
+ approximate by construction, since it sums whole run dirs for archive/delete
489
+ but only the trimmed scaffolding (`runs.heavy_run_entries`) for a trim.
490
+
491
+ Every list names items the text enumerates or counts: `worktrees` holds
492
+ absolute worktree paths, the rest hold run ids. `protected` is the runs left
493
+ untouched — `--keep`-listed, non-terminal, or carrying a live agent session,
494
+ which protects a run wherever it sits relative to the retention window
495
+ (reclaiming it would strand the session, #419) — which the text reports only
496
+ as a count. `unverifiable_pid` is the subset
497
+ of touched runs whose engine liveness could not be proven; it is the text
498
+ mode's stderr warning, carried in the document so JSON mode leaves stderr
499
+ empty, and it never blocks reclamation.
500
+
501
+ `policy.retain` is the *effective* window — `--retain` when given, else
502
+ `[cleanup] run_retention`. The other three are the configured policy as
503
+ loaded. Note `--hard` overrides `archive_old` for this invocation only, so
504
+ it does not change the reported value; the outcome shows in `deleted`.
505
+
506
+ `state_dirs_swept` counts the orphaned out-of-tree control-plane dirs the
507
+ invocation reclaimed (#494) — run state dirs under the user-scoped state root
508
+ with no run dir left to own them. A count rather than a list, because that is
509
+ exactly what the text mode reports and the paths name a location outside the
510
+ project that no caller acts on per-item. It is an additive field on the
511
+ existing schema version: a v1 consumer reads every field it already knew.
512
+ Those dirs hold only consumed event files, so their bytes are not in
513
+ `freed_bytes` — an accepted under-count of a few kilobytes at most.
514
+ """
515
+ return {
516
+ "schema_version": CLEAN_SCHEMA_VERSION,
517
+ "dry_run": dry_run,
518
+ "policy": {
519
+ "retain": retain,
520
+ "retention_days": cleanup_policy.retention_days,
521
+ "archive_old": cleanup_policy.archive_old,
522
+ "trim_artifacts": cleanup_policy.trim_artifacts,
523
+ },
524
+ "freed_bytes": freed_bytes,
525
+ "worktrees": list(worktrees),
526
+ "trimmed": list(trimmed),
527
+ "archived": list(archived),
528
+ "deleted": list(deleted),
529
+ "protected": list(protected),
530
+ "unverifiable_pid": list(unverifiable_pid),
531
+ "state_dirs_swept": state_dirs_swept,
532
+ }