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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/documents.py
ADDED
|
@@ -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
|
+
}
|