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/tui/data.py
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
"""Read-only filesystem observation layer for the TUI.
|
|
2
|
+
|
|
3
|
+
Everything the dashboard shows comes from the run-dir artifacts the engine
|
|
4
|
+
already writes atomically: state.json (os.replace), journal.jsonl
|
|
5
|
+
(append-only), logs/<task-id>.log, ATTENTION. This module never imports
|
|
6
|
+
textual — it is plain stdlib + core modules + pyte/rich, fully unit-testable,
|
|
7
|
+
and the screens own the poll cadence.
|
|
8
|
+
|
|
9
|
+
The run inventory itself (``discover_runs`` and its status/liveness closure) now
|
|
10
|
+
lives in :mod:`froid_loop.runs` — the core CLI's ``list`` needs it and must not
|
|
11
|
+
drag pyte/rich in with it (#650). It is re-exported below, so ``data.<name>``
|
|
12
|
+
stays the TUI-facing spelling.
|
|
13
|
+
|
|
14
|
+
All readers are stat-gated: parse results are cached while the file's
|
|
15
|
+
(mtime_ns, size, inode) is unchanged. Liveness is the exception — a dying engine
|
|
16
|
+
changes no file, so the pid is re-checked on every call.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import bisect
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from collections import deque
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import pyte
|
|
30
|
+
from rich.color import ColorParseError
|
|
31
|
+
from rich.style import Style
|
|
32
|
+
from rich.text import Text
|
|
33
|
+
|
|
34
|
+
from .. import froidconfig, deferredwork, policy, sprintstatus, stories
|
|
35
|
+
from ..gates import ATTENTION_FILE
|
|
36
|
+
from ..journal import JOURNAL_FILE, LOGS_DIR, STATE_FILE, load_state
|
|
37
|
+
from ..model import RunState
|
|
38
|
+
from ..platform_util import resolve_or_lexical
|
|
39
|
+
|
|
40
|
+
# Run-inventory names relocated to froid_loop.runs (#650) and re-exported here so
|
|
41
|
+
# tui.data stays the TUI-facing surface (`data.discover_runs`, `data.RUNNING`,
|
|
42
|
+
# monkeypatched `data.liveness` in tests). Each pin is load-bearing: without it
|
|
43
|
+
# ruff F401 autofix deletes the re-export and every `data.<name>` consumer breaks.
|
|
44
|
+
from ..runs import CRASHED # noqa: F401 — re-export
|
|
45
|
+
from ..runs import FINISHED # noqa: F401 — re-export
|
|
46
|
+
from ..runs import INTERRUPTED # noqa: F401 — re-export
|
|
47
|
+
from ..runs import PAUSED # noqa: F401 — re-export
|
|
48
|
+
from ..runs import RUNNING # noqa: F401 — re-export
|
|
49
|
+
from ..runs import STOPPED # noqa: F401 — re-export
|
|
50
|
+
from ..runs import RunInfo # noqa: F401 — re-export
|
|
51
|
+
from ..runs import _header_cache # noqa: F401 — re-export
|
|
52
|
+
from ..runs import _session_liveness # noqa: F401 — re-export
|
|
53
|
+
from ..runs import discover_runs # noqa: F401 — re-export
|
|
54
|
+
from ..runs import (
|
|
55
|
+
STOP_REQUEST_FILE,
|
|
56
|
+
UNKNOWN,
|
|
57
|
+
_classify,
|
|
58
|
+
_stat_sig,
|
|
59
|
+
_StatSig,
|
|
60
|
+
list_run_dirs,
|
|
61
|
+
liveness,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------- run watching
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RunWatcher:
|
|
68
|
+
"""Stat-gated views of one run dir. Never raises on missing or mid-write
|
|
69
|
+
files: state() keeps returning the last good parse."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, run_dir: Path):
|
|
72
|
+
self.run_dir = run_dir
|
|
73
|
+
self._state_sig: _StatSig | None = None
|
|
74
|
+
self._state: RunState | None = None
|
|
75
|
+
self._attention_sig: _StatSig | None = None
|
|
76
|
+
self._attention = ""
|
|
77
|
+
|
|
78
|
+
def state(self) -> RunState | None:
|
|
79
|
+
sig = _stat_sig(self.run_dir / STATE_FILE)
|
|
80
|
+
if sig is None or sig == self._state_sig:
|
|
81
|
+
return self._state
|
|
82
|
+
try:
|
|
83
|
+
self._state = load_state(self.run_dir)
|
|
84
|
+
self._state_sig = sig
|
|
85
|
+
except (OSError, json.JSONDecodeError, KeyError, ValueError):
|
|
86
|
+
pass # keep last good
|
|
87
|
+
return self._state
|
|
88
|
+
|
|
89
|
+
def liveness(self) -> str:
|
|
90
|
+
return liveness(self.run_dir)
|
|
91
|
+
|
|
92
|
+
def status(self) -> str:
|
|
93
|
+
state = self.state()
|
|
94
|
+
if state is None:
|
|
95
|
+
return UNKNOWN
|
|
96
|
+
return _classify(state.finished, state.paused, state.stopped, state.crashed, self.run_dir)
|
|
97
|
+
|
|
98
|
+
def stopping(self) -> bool:
|
|
99
|
+
"""True when a stop request of *either* mode is pending for this run (its
|
|
100
|
+
control file is present) — a bare existence read for the run-header pending
|
|
101
|
+
line, mirroring runs.graceful_stop_requested. Deliberately mode-blind: a run
|
|
102
|
+
with a hard request lodged (#319) is stopping too, and the request is on disk
|
|
103
|
+
only for the seconds it takes the engine to honor it. The caller gates on a
|
|
104
|
+
RUNNING status so a file lingering on a stopped run doesn't read as
|
|
105
|
+
still-stopping."""
|
|
106
|
+
return (self.run_dir / STOP_REQUEST_FILE).is_file()
|
|
107
|
+
|
|
108
|
+
def attention(self) -> str:
|
|
109
|
+
path = self.run_dir / ATTENTION_FILE
|
|
110
|
+
sig = _stat_sig(path)
|
|
111
|
+
if sig is not None and sig != self._attention_sig:
|
|
112
|
+
try:
|
|
113
|
+
self._attention = path.read_text(encoding="utf-8")
|
|
114
|
+
self._attention_sig = sig
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
return self._attention
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class JournalTail:
|
|
121
|
+
"""Incremental journal.jsonl reader.
|
|
122
|
+
|
|
123
|
+
The byte offset only ever advances past complete lines, so a partially
|
|
124
|
+
flushed append is withheld until its newline lands. Truncation
|
|
125
|
+
(size < offset) resets to the start; unparseable lines are skipped.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, run_dir: Path):
|
|
129
|
+
self.path = run_dir / JOURNAL_FILE
|
|
130
|
+
self._offset = 0
|
|
131
|
+
|
|
132
|
+
def read_new(self) -> list[dict[str, Any]]:
|
|
133
|
+
sig = _stat_sig(self.path)
|
|
134
|
+
if sig is None:
|
|
135
|
+
self._offset = 0
|
|
136
|
+
return []
|
|
137
|
+
size = sig[1]
|
|
138
|
+
if size < self._offset:
|
|
139
|
+
self._offset = 0
|
|
140
|
+
if size == self._offset:
|
|
141
|
+
return []
|
|
142
|
+
with self.path.open("rb") as f:
|
|
143
|
+
f.seek(self._offset)
|
|
144
|
+
chunk = f.read(size - self._offset)
|
|
145
|
+
complete = chunk.rfind(b"\n") + 1
|
|
146
|
+
if complete == 0:
|
|
147
|
+
return []
|
|
148
|
+
self._offset += complete
|
|
149
|
+
entries: list[dict[str, Any]] = []
|
|
150
|
+
for raw in chunk[:complete].splitlines():
|
|
151
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
152
|
+
if not line:
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
entry = json.loads(line)
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
continue
|
|
158
|
+
if isinstance(entry, dict):
|
|
159
|
+
entries.append(entry)
|
|
160
|
+
return entries
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Pane geometry mirrors adapters.generic PANE_COLUMNS/PANE_LINES (not
|
|
164
|
+
# imported: that module drags in Policy/SignalWatcher, and this layer stays
|
|
165
|
+
# a pure observer).
|
|
166
|
+
_PANE_COLUMNS = 220
|
|
167
|
+
_PANE_LINES = 50
|
|
168
|
+
_HISTORY_LINES = 2000 # matches the dashboard RichLog max_lines
|
|
169
|
+
|
|
170
|
+
# pyte names SGR 33 "brown"; aixterm brights carry no underscore. Values here
|
|
171
|
+
# must stay in pyte's own (underscore-free) namespace: _rich_color re-applies
|
|
172
|
+
# the "bright" -> "bright_" transform after this remap, so a "bright_"-prefixed
|
|
173
|
+
# value would double up into "bright__yellow" (an invalid rich color).
|
|
174
|
+
# "bfightmagenta" is pyte 0.8.2's own BG_AIXTERM[105] typo (SGR 105).
|
|
175
|
+
_PYTE_COLOR_FIX = {
|
|
176
|
+
"brown": "yellow",
|
|
177
|
+
"brightbrown": "brightyellow",
|
|
178
|
+
"bfightmagenta": "brightmagenta",
|
|
179
|
+
}
|
|
180
|
+
_HEX_DIGITS = set("0123456789abcdef")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _rich_color(value: str) -> str | None:
|
|
184
|
+
"""pyte color -> rich color: 'default' (None), bare rrggbb hex
|
|
185
|
+
(256/truecolor), or a named ANSI color."""
|
|
186
|
+
if value == "default":
|
|
187
|
+
return None
|
|
188
|
+
if len(value) == 6 and set(value) <= _HEX_DIGITS:
|
|
189
|
+
return f"#{value}"
|
|
190
|
+
value = _PYTE_COLOR_FIX.get(value, value)
|
|
191
|
+
if value.startswith("bright"):
|
|
192
|
+
return "bright_" + value[6:]
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_style_cache: dict[tuple, Style] = {}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _char_style(key: tuple) -> Style:
|
|
200
|
+
style = _style_cache.get(key)
|
|
201
|
+
if style is None:
|
|
202
|
+
fg, bg, bold, italics, underscore, strikethrough, reverse = key
|
|
203
|
+
attrs = dict(
|
|
204
|
+
bold=bold or None,
|
|
205
|
+
italic=italics or None,
|
|
206
|
+
underline=underscore or None,
|
|
207
|
+
strike=strikethrough or None,
|
|
208
|
+
reverse=reverse or None,
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
style = Style(color=_rich_color(fg), bgcolor=_rich_color(bg), **attrs)
|
|
212
|
+
except ColorParseError:
|
|
213
|
+
# A color name rich can't parse (e.g. a new pyte table entry) must
|
|
214
|
+
# degrade to an uncolored run, not kill the poll worker — and with
|
|
215
|
+
# it the app (textual workers default to exit_on_error=True).
|
|
216
|
+
style = Style(**attrs)
|
|
217
|
+
_style_cache[key] = style
|
|
218
|
+
return style
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _render_row(row: dict) -> Text:
|
|
222
|
+
"""One pyte buffer row (sparse col -> Char dict) -> styled Text. Trailing
|
|
223
|
+
default-background whitespace is trimmed; wide-char stub cells (data '')
|
|
224
|
+
concatenate away naturally."""
|
|
225
|
+
width = max(row, default=-1) + 1
|
|
226
|
+
while width:
|
|
227
|
+
ch = row[width - 1]
|
|
228
|
+
if ch.data not in (" ", "") or ch.bg != "default" or ch.reverse:
|
|
229
|
+
break
|
|
230
|
+
width -= 1
|
|
231
|
+
text = Text()
|
|
232
|
+
run: list[str] = []
|
|
233
|
+
prev_key: tuple | None = None
|
|
234
|
+
for x in range(width):
|
|
235
|
+
ch = row[x]
|
|
236
|
+
key = (ch.fg, ch.bg, ch.bold, ch.italics, ch.underscore, ch.strikethrough, ch.reverse)
|
|
237
|
+
if key != prev_key and run:
|
|
238
|
+
# prev_key is non-None whenever `run` is non-empty (a prior cell was buffered).
|
|
239
|
+
text.append("".join(run), _char_style(prev_key)) # pyright: ignore[reportArgumentType]
|
|
240
|
+
run.clear()
|
|
241
|
+
prev_key = key
|
|
242
|
+
run.append(ch.data)
|
|
243
|
+
if run and prev_key is not None:
|
|
244
|
+
text.append("".join(run), _char_style(prev_key))
|
|
245
|
+
return text
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# CSI sequences with a private/secondary marker (< > = ?) are terminal capability
|
|
249
|
+
# negotiation, never display SGR. pyte 0.8.2 ignores a leading marker and
|
|
250
|
+
# misdispatches them to SGR anyway: e.g. XTMODKEYS `CSI > 4 ; 2 m` (modifyOtherKeys,
|
|
251
|
+
# emitted at session start by Claude Code et al.) is read as SGR 4 = underline-on,
|
|
252
|
+
# leaving the whole log underlined until an exit-time disable a live capture never
|
|
253
|
+
# contains. Worse, a marker byte *inside* the params — gemini's `CSI > 4 ; ? m`,
|
|
254
|
+
# vim9's `CSI ? 4 m` — makes pyte dispatch with private=True to a handler that
|
|
255
|
+
# rejects the kwarg, a TypeError that would kill the poll worker (#111; upstream
|
|
256
|
+
# selectel/pyte#202, fixed in master but never released). Strip them before pyte
|
|
257
|
+
# sees them, matching the marker anywhere in the param bytes; a legitimate SGR
|
|
258
|
+
# carries no marker, so this can only remove non-display sequences (never printable
|
|
259
|
+
# text or genuine styling).
|
|
260
|
+
_PRIVATE_MARKER_SGR = re.compile(rb"\x1b\[[0-9;:<>=?]*[<>=?][0-9;:<>=?]*m")
|
|
261
|
+
# Alternate-screen switch sequences (DECSET/DECRST 1049/1047/47). A CLI fullscreen
|
|
262
|
+
# TUI (Claude Code's fullscreen renderer) switches here and repaints in place; pyte
|
|
263
|
+
# has no altscreen buffer, so the capture collapses to the final frame. Detecting
|
|
264
|
+
# the switch lets a consumer flag a fullscreen log instead of trusting it whole.
|
|
265
|
+
# Both enter (h) and exit (l) markers are matched: a finished run read from the
|
|
266
|
+
# max_bytes tail can seek past the start-of-session enter but still carries the
|
|
267
|
+
# exit emitted when the CLI quit.
|
|
268
|
+
_ALTSCREEN_MARKERS = (
|
|
269
|
+
b"\x1b[?1049h",
|
|
270
|
+
b"\x1b[?1049l",
|
|
271
|
+
b"\x1b[?1047h",
|
|
272
|
+
b"\x1b[?1047l",
|
|
273
|
+
b"\x1b[?47h",
|
|
274
|
+
b"\x1b[?47l",
|
|
275
|
+
)
|
|
276
|
+
# Cold-open altscreen detection scans the max_bytes-skipped prefix, but caps it so a
|
|
277
|
+
# huge log doesn't trigger a near-whole-file read that would defeat max_bytes. 8 MiB
|
|
278
|
+
# comfortably covers a multi-MB classic preamble before a TUI switches to the
|
|
279
|
+
# alternate screen (observed ~3.9 MB). A marker past the cap is missed only when the
|
|
280
|
+
# log already exceeds max_bytes at first open (that first read tail-seeks past the
|
|
281
|
+
# prefix); a view attached while the log was still small reads the whole stream from
|
|
282
|
+
# offset 0 and catches it regardless.
|
|
283
|
+
_ALTSCREEN_PREFIX_SCAN_CAP = 8 << 20
|
|
284
|
+
# A trailing, not-yet-terminated CSI at the end of a read: ESC, or ESC[ followed by
|
|
285
|
+
# only param/marker bytes with no final letter. Held back so a marker sequence split
|
|
286
|
+
# across two reads is filtered whole next time (the filter must see it complete).
|
|
287
|
+
_INCOMPLETE_CSI_TAIL = re.compile(rb"\x1b(?:\[[0-9;:<>=?]*)?\Z")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _strip_private_marker_sgr(chunk: bytes) -> tuple[bytes, int]:
|
|
291
|
+
"""(filtered chunk, bytes held back). Drops private-marker SGR sequences and
|
|
292
|
+
returns the length of an unterminated trailing CSI that should not be consumed
|
|
293
|
+
yet, so the caller can re-read it next time and see the sequence whole."""
|
|
294
|
+
m = _INCOMPLETE_CSI_TAIL.search(chunk)
|
|
295
|
+
held = len(m.group()) if m else 0
|
|
296
|
+
body = chunk[: len(chunk) - held] if held else chunk
|
|
297
|
+
return _PRIVATE_MARKER_SGR.sub(b"", body), held
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class _TolerantByteStream(pyte.ByteStream):
|
|
301
|
+
"""pyte 0.8.2 dispatches a private-marked CSI (marker byte in the params) with
|
|
302
|
+
private=True to handlers that don't accept it, raising TypeError (upstream
|
|
303
|
+
selectel/pyte#202; fixed in master Sep 2025, never released). Any parser
|
|
304
|
+
exception here would kill the poll worker — and with it the app — so drop the
|
|
305
|
+
unparseable sequence instead: the base method re-initializes the parser before
|
|
306
|
+
re-raising, leaving the stream usable for the bytes that follow."""
|
|
307
|
+
|
|
308
|
+
def _send_to_parser(self, data: str) -> bool | None:
|
|
309
|
+
try:
|
|
310
|
+
return super()._send_to_parser(data)
|
|
311
|
+
except Exception:
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class _CountingDeque(deque):
|
|
316
|
+
"""history.top replacement that counts rows permanently gone above the
|
|
317
|
+
window: maxlen evictions plus the clear() from pyte's reset()/ED-3.
|
|
318
|
+
Lets checkpoints live in absolute line coordinates while the deque
|
|
319
|
+
slides (indices into the deque itself shift on every eviction)."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, maxlen: int | None = None):
|
|
322
|
+
super().__init__(maxlen=maxlen)
|
|
323
|
+
self.dropped = 0
|
|
324
|
+
|
|
325
|
+
def append(self, item: Any) -> None:
|
|
326
|
+
if self.maxlen is not None and len(self) == self.maxlen:
|
|
327
|
+
self.dropped += 1
|
|
328
|
+
super().append(item)
|
|
329
|
+
|
|
330
|
+
def clear(self) -> None:
|
|
331
|
+
self.dropped += len(self)
|
|
332
|
+
super().clear()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@dataclass(frozen=True)
|
|
336
|
+
class LogIndex:
|
|
337
|
+
"""Maps journal log_pos byte offsets to rendered-line indices.
|
|
338
|
+
|
|
339
|
+
Built by the poll worker right after render() and handed across to the
|
|
340
|
+
UI thread, so it is frozen. Line indices match render() output rows
|
|
341
|
+
one-to-one — which equals RichLog scroll lines while the log pane keeps
|
|
342
|
+
wrap=False (the textual default)."""
|
|
343
|
+
|
|
344
|
+
checkpoints: tuple[tuple[int, int], ...] # ascending (file_offset, absolute_line)
|
|
345
|
+
render_base: int # absolute line index of render() row 0
|
|
346
|
+
render_len: int # rows in the last render()
|
|
347
|
+
|
|
348
|
+
def line_for_offset(self, offset: int) -> int | None:
|
|
349
|
+
"""Rendered-line index for a byte offset into the log file; None
|
|
350
|
+
when nothing is rendered. Between checkpoints the line is
|
|
351
|
+
interpolated by byte fraction — exact for plain streaming output,
|
|
352
|
+
bounded by the surrounding checkpoints for repaint-heavy segments.
|
|
353
|
+
Offsets below the tail seek or evicted history clamp to the first
|
|
354
|
+
row, offsets past EOF to the last."""
|
|
355
|
+
if self.render_len <= 0:
|
|
356
|
+
return None
|
|
357
|
+
cps = self.checkpoints
|
|
358
|
+
i = bisect.bisect_right(cps, (offset, float("inf"))) - 1
|
|
359
|
+
if i < 0:
|
|
360
|
+
absolute = 0
|
|
361
|
+
elif i + 1 < len(cps) and cps[i + 1][0] > cps[i][0]:
|
|
362
|
+
o0, l0 = cps[i]
|
|
363
|
+
o1, l1 = cps[i + 1]
|
|
364
|
+
absolute = l0 + round((offset - o0) * (l1 - l0) / (o1 - o0))
|
|
365
|
+
else:
|
|
366
|
+
absolute = cps[i][1]
|
|
367
|
+
return max(0, min(absolute - self.render_base, self.render_len - 1))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class LogView:
|
|
371
|
+
"""Terminal-emulated view of a pane log (a raw pipe-pane capture full of
|
|
372
|
+
cursor-addressed repaints). Bytes are fed through pyte so the stream
|
|
373
|
+
collapses to what a real pane-sized terminal shows; scrolled-off lines
|
|
374
|
+
land in history, so a finished run shows more than the final screen.
|
|
375
|
+
Same stat-gated incremental contract as JournalTail: the first read seeks
|
|
376
|
+
to the last max_bytes, truncation resets — the emulator included.
|
|
377
|
+
|
|
378
|
+
Bytes are fed in checkpoint_bytes slices, recording (file offset,
|
|
379
|
+
absolute cursor line) pairs so journal log_pos offsets map back to
|
|
380
|
+
rendered lines (see LogIndex). Cursor-addressed repaints make the
|
|
381
|
+
mapping approximate — the cursor sits wherever the CLI left it — but
|
|
382
|
+
it is exact for plain streaming output.
|
|
383
|
+
|
|
384
|
+
Known degradations, all strictly better than rendering the raw stream:
|
|
385
|
+
a mid-stream first frame may be partial until the next repaint; altscreen
|
|
386
|
+
(mode 1049) CLIs merge frames into one buffer; a human attaching to the
|
|
387
|
+
tmux session resizes the pane away from our fixed geometry."""
|
|
388
|
+
|
|
389
|
+
def __init__(
|
|
390
|
+
self,
|
|
391
|
+
path: Path,
|
|
392
|
+
max_bytes: int = 262144,
|
|
393
|
+
columns: int = _PANE_COLUMNS,
|
|
394
|
+
lines: int = _PANE_LINES,
|
|
395
|
+
history: int = _HISTORY_LINES,
|
|
396
|
+
checkpoint_bytes: int = 4096,
|
|
397
|
+
):
|
|
398
|
+
self.path = path
|
|
399
|
+
self.max_bytes = max_bytes
|
|
400
|
+
self._columns, self._lines, self._history = columns, lines, history
|
|
401
|
+
self._checkpoint_bytes = checkpoint_bytes
|
|
402
|
+
self._offset: int | None = None # None until the file first appears
|
|
403
|
+
self._row_cache: dict[int, Text] = {} # id(history row) -> rendered
|
|
404
|
+
# True once the stream enters the alternate screen (CLI fullscreen TUI):
|
|
405
|
+
# pyte has no altscreen buffer, so those frames repaint the one screen in
|
|
406
|
+
# place and the render collapses to the final frame. A consumer surfaces
|
|
407
|
+
# this so a fullscreen capture isn't mistaken for the whole session.
|
|
408
|
+
self.altscreen_seen = False
|
|
409
|
+
self._reset_screen()
|
|
410
|
+
|
|
411
|
+
def _reset_screen(self) -> None:
|
|
412
|
+
self._screen = pyte.HistoryScreen(self._columns, self._lines, history=self._history)
|
|
413
|
+
self._screen.history = self._screen.history._replace(
|
|
414
|
+
top=_CountingDeque(maxlen=self._history)
|
|
415
|
+
)
|
|
416
|
+
self._stream = _TolerantByteStream(self._screen)
|
|
417
|
+
self._row_cache.clear()
|
|
418
|
+
self._checkpoints: list[tuple[int, int]] = []
|
|
419
|
+
self._render_base = 0
|
|
420
|
+
self._render_len = 0
|
|
421
|
+
# A truncation restart re-reads from scratch, so re-detect altscreen too.
|
|
422
|
+
self.altscreen_seen = False
|
|
423
|
+
|
|
424
|
+
def _scan_prefix_for_altscreen(self, end: int) -> None:
|
|
425
|
+
"""One-time scan of bytes [0, end) for an altscreen switch marker, in
|
|
426
|
+
overlapping windows so a marker straddling a window boundary still
|
|
427
|
+
matches. `end` is the caller-bounded scan ceiling (see
|
|
428
|
+
_ALTSCREEN_PREFIX_SCAN_CAP), not necessarily the full prefix. Sets
|
|
429
|
+
altscreen_seen; best-effort, never raises."""
|
|
430
|
+
overlap = max(len(m) for m in _ALTSCREEN_MARKERS) - 1
|
|
431
|
+
window = 1 << 20
|
|
432
|
+
try:
|
|
433
|
+
with self.path.open("rb") as f:
|
|
434
|
+
pos = 0
|
|
435
|
+
while pos < end:
|
|
436
|
+
f.seek(pos)
|
|
437
|
+
# window + overlap so a marker straddling a window boundary is
|
|
438
|
+
# caught; clamped to `end` so the cap stays a strict ceiling.
|
|
439
|
+
buf = f.read(min(window + overlap, end - pos))
|
|
440
|
+
if any(seq in buf for seq in _ALTSCREEN_MARKERS):
|
|
441
|
+
self.altscreen_seen = True
|
|
442
|
+
return
|
|
443
|
+
if not buf:
|
|
444
|
+
return
|
|
445
|
+
pos += window
|
|
446
|
+
except OSError:
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
def read_new(self) -> bool:
|
|
450
|
+
"""Feed any new bytes into the emulator; True when content changed."""
|
|
451
|
+
sig = _stat_sig(self.path)
|
|
452
|
+
if sig is None:
|
|
453
|
+
if self._offset is None:
|
|
454
|
+
return False
|
|
455
|
+
self._offset = None
|
|
456
|
+
self._reset_screen()
|
|
457
|
+
return True
|
|
458
|
+
size = sig[1]
|
|
459
|
+
if self._offset is None:
|
|
460
|
+
self._offset = max(0, size - self.max_bytes)
|
|
461
|
+
# offsets at or before the tail seek clamp to the first line
|
|
462
|
+
self._checkpoints = [(self._offset, 0)]
|
|
463
|
+
# The render only emulates the max_bytes tail, but an altscreen switch
|
|
464
|
+
# is a whole-session property whose markers often sit in the skipped
|
|
465
|
+
# prefix (enter at session start). Scan that prefix once on open so a
|
|
466
|
+
# cold-opened fullscreen log is still flagged; the tail is covered by
|
|
467
|
+
# the per-chunk scan below.
|
|
468
|
+
if self._offset > 0 and not self.altscreen_seen:
|
|
469
|
+
self._scan_prefix_for_altscreen(min(self._offset, _ALTSCREEN_PREFIX_SCAN_CAP))
|
|
470
|
+
elif size < self._offset:
|
|
471
|
+
self._offset = 0
|
|
472
|
+
self._reset_screen()
|
|
473
|
+
self._checkpoints = [(0, 0)]
|
|
474
|
+
if size == self._offset:
|
|
475
|
+
return False
|
|
476
|
+
with self.path.open("rb") as f:
|
|
477
|
+
f.seek(self._offset)
|
|
478
|
+
chunk = f.read(size - self._offset)
|
|
479
|
+
if not self.altscreen_seen and any(seq in chunk for seq in _ALTSCREEN_MARKERS):
|
|
480
|
+
self.altscreen_seen = True
|
|
481
|
+
# Drop private-marker SGR (pyte misreads it as underline); hold back an
|
|
482
|
+
# unterminated trailing CSI so a sequence split across reads is filtered
|
|
483
|
+
# whole next time. `consumed` is the original byte span we commit here —
|
|
484
|
+
# the file offset advances over it while pyte sees only the filtered bytes.
|
|
485
|
+
filtered, held = _strip_private_marker_sgr(chunk)
|
|
486
|
+
consumed = len(chunk) - held
|
|
487
|
+
base, total = self._offset, len(filtered)
|
|
488
|
+
top = self._screen.history.top
|
|
489
|
+
for start in range(0, total, self._checkpoint_bytes):
|
|
490
|
+
piece = filtered[start : start + self._checkpoint_bytes]
|
|
491
|
+
# ByteStream buffers escape sequences split across feeds
|
|
492
|
+
self._stream.feed(piece)
|
|
493
|
+
# map back to an original-file offset (filtering only removes a few
|
|
494
|
+
# marker bytes; the log_pos->line mapping is already approximate)
|
|
495
|
+
end = start + len(piece)
|
|
496
|
+
self._offset = base + (consumed if end >= total else round(end / total * consumed))
|
|
497
|
+
# pyte types history.top as base deque; froid-loop installs a _CountingDeque
|
|
498
|
+
# (see class below) that adds `.dropped`.
|
|
499
|
+
line = (
|
|
500
|
+
top.dropped # pyright: ignore[reportAttributeAccessIssue]
|
|
501
|
+
+ len(top)
|
|
502
|
+
+ self._screen.cursor.y
|
|
503
|
+
)
|
|
504
|
+
self._checkpoints.append((self._offset, line))
|
|
505
|
+
self._offset = base + consumed
|
|
506
|
+
# When a chunk is entirely an unterminated trailing CSI, consumed == 0 and
|
|
507
|
+
# _offset holds short of EOF until more bytes complete the sequence — a few
|
|
508
|
+
# held bytes re-read per poll. We deliberately don't advance past them: a
|
|
509
|
+
# log that's still streaming self-heals on the next write, and one that
|
|
510
|
+
# permanently ends mid-escape has nothing renderable there anyway (we'd
|
|
511
|
+
# only risk eating a legitimately split sequence by forcing it through).
|
|
512
|
+
# drop checkpoints whose lines evicted past the history horizon;
|
|
513
|
+
# their offsets would clamp to line 0 anyway
|
|
514
|
+
# `.dropped` is on the _CountingDeque froid-loop installs as history.top.
|
|
515
|
+
while (
|
|
516
|
+
len(self._checkpoints) > 1
|
|
517
|
+
and self._checkpoints[1][1]
|
|
518
|
+
<= top.dropped # pyright: ignore[reportAttributeAccessIssue]
|
|
519
|
+
):
|
|
520
|
+
self._checkpoints.pop(0)
|
|
521
|
+
return consumed > 0
|
|
522
|
+
|
|
523
|
+
def index(self) -> LogIndex:
|
|
524
|
+
"""Snapshot for log_pos -> rendered-line lookups; reflects the most
|
|
525
|
+
recent render() (render_base/render_len are set there)."""
|
|
526
|
+
return LogIndex(tuple(self._checkpoints), self._render_base, self._render_len)
|
|
527
|
+
|
|
528
|
+
def render(self) -> Text:
|
|
529
|
+
"""History + current screen as one styled Text, trailing blank rows
|
|
530
|
+
dropped, capped to the newest history rows. History rows are detached
|
|
531
|
+
dicts that never mutate, so their renders are memoized by id() —
|
|
532
|
+
valid exactly while the row sits in the deque (the cache is re-keyed
|
|
533
|
+
every call, keeping only surviving ids)."""
|
|
534
|
+
screen = self._screen
|
|
535
|
+
fresh: dict[int, Text] = {}
|
|
536
|
+
rows: list[Text] = []
|
|
537
|
+
for row in screen.history.top:
|
|
538
|
+
text = self._row_cache.get(id(row))
|
|
539
|
+
if text is None:
|
|
540
|
+
text = _render_row(row)
|
|
541
|
+
fresh[id(row)] = text
|
|
542
|
+
rows.append(text)
|
|
543
|
+
self._row_cache = fresh
|
|
544
|
+
rows += [_render_row(screen.buffer[y]) for y in range(screen.lines)]
|
|
545
|
+
while rows and not rows[-1].plain:
|
|
546
|
+
rows.pop()
|
|
547
|
+
front_drop = max(0, len(rows) - self._history)
|
|
548
|
+
del rows[:front_drop]
|
|
549
|
+
# `.dropped` is on the _CountingDeque froid-loop installs as history.top.
|
|
550
|
+
self._render_base = (
|
|
551
|
+
screen.history.top.dropped + front_drop # pyright: ignore[reportAttributeAccessIssue]
|
|
552
|
+
)
|
|
553
|
+
self._render_len = len(rows)
|
|
554
|
+
return Text("\n").join(rows)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _open_session_start(journal_entries: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
558
|
+
"""The session-start entry whose agent session is currently open: the last
|
|
559
|
+
session-start with no later matching session-end. None when every started
|
|
560
|
+
session has ended (or none started). The task_id is tracked as a string so
|
|
561
|
+
the session-end match is byte-identical to what active_task_id compared."""
|
|
562
|
+
open_entry: dict[str, Any] | None = None
|
|
563
|
+
active: str | None = None
|
|
564
|
+
for entry in journal_entries:
|
|
565
|
+
kind = entry.get("kind")
|
|
566
|
+
if kind == "session-start" and entry.get("task_id") is not None:
|
|
567
|
+
active = str(entry["task_id"])
|
|
568
|
+
open_entry = entry
|
|
569
|
+
elif kind == "session-end" and str(entry.get("task_id")) == active:
|
|
570
|
+
active = None
|
|
571
|
+
open_entry = None
|
|
572
|
+
return open_entry
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def active_task_id(run_dir: Path, journal_entries: list[dict[str, Any]]) -> str | None:
|
|
576
|
+
"""Task whose agent session is currently open: the last session-start
|
|
577
|
+
without a later session-end. Falls back to the newest file in logs/ —
|
|
578
|
+
a tail attached mid-session has no start event in view."""
|
|
579
|
+
entry = _open_session_start(journal_entries)
|
|
580
|
+
if entry is not None:
|
|
581
|
+
return str(entry["task_id"])
|
|
582
|
+
try:
|
|
583
|
+
logs = sorted(
|
|
584
|
+
(run_dir / LOGS_DIR).glob("*.log"),
|
|
585
|
+
key=lambda p: p.stat().st_mtime_ns,
|
|
586
|
+
)
|
|
587
|
+
except OSError:
|
|
588
|
+
return None
|
|
589
|
+
return logs[-1].stem if logs else None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@dataclass(frozen=True)
|
|
593
|
+
class ActiveAgent:
|
|
594
|
+
"""Identity of the agent currently driving a run, derived from the open
|
|
595
|
+
session-start journal entry: the task/story it is working, the stage role,
|
|
596
|
+
and the resolved adapter name + model."""
|
|
597
|
+
|
|
598
|
+
task_id: str
|
|
599
|
+
story_key: str
|
|
600
|
+
role: str
|
|
601
|
+
name: str
|
|
602
|
+
model: str
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _story_key_from_task_id(task_id: str, role: str) -> str:
|
|
606
|
+
"""Recover the story key from a session task_id when the journal entry
|
|
607
|
+
predates story-key stamping (#153 phase 1). The id is
|
|
608
|
+
``safe_segment(f"{story_key}-{part}-{seq}{gen}")`` where ``part`` is the role, or
|
|
609
|
+
a workflow label for labeled plugin sessions, and ``gen`` is a ``-g<N>`` re-arm
|
|
610
|
+
generation suffix emitted only above zero (#705). This parser handles the
|
|
611
|
+
unsuffixed shape ONLY: a ``-g1`` tail fails the ``seq.isdigit()`` test below and
|
|
612
|
+
returns the whole id. That is unreachable rather than latent-correct — every
|
|
613
|
+
session-start has carried ``story_key`` since #153 phase 1, so the entries this
|
|
614
|
+
fallback sees are exactly the ones that predate generations — but widen the
|
|
615
|
+
fallback and this is the assumption that breaks. So peel the trailing
|
|
616
|
+
``-{part}-{seq}``: drop the numeric seq, then the recorded role when it
|
|
617
|
+
matches (the common case), else one more ``-`` group (best-effort, since a
|
|
618
|
+
label is not recoverable from the entry)."""
|
|
619
|
+
head, sep, seq = task_id.rpartition("-")
|
|
620
|
+
if not sep or not seq.isdigit():
|
|
621
|
+
return task_id # not the expected shape — best we can do
|
|
622
|
+
if role and head.endswith(f"-{role}"):
|
|
623
|
+
return head[: -(len(role) + 1)]
|
|
624
|
+
parent = head.rpartition("-")[0]
|
|
625
|
+
return parent or head
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def active_agent(
|
|
629
|
+
journal_entries: list[dict[str, Any]], policy_snapshot: dict[str, Any] | None
|
|
630
|
+
) -> ActiveAgent | None:
|
|
631
|
+
"""The agent currently driving the run, or None when no session is open.
|
|
632
|
+
|
|
633
|
+
Journal-only: unlike :func:`active_task_id` there is no logs/ fallback — a
|
|
634
|
+
tail attached mid-session carries no adapter identity. A session-start
|
|
635
|
+
stamped since #153 phase 1 supplies the adapter name/model (and story_key)
|
|
636
|
+
directly. For an older, unstamped entry the run's persisted
|
|
637
|
+
``policy_snapshot`` is rebuilt and resolved for the entry's role; when that
|
|
638
|
+
yields nothing trustworthy (no/empty snapshot) the agent is unknown -> None.
|
|
639
|
+
Never raises on a malformed entry."""
|
|
640
|
+
try:
|
|
641
|
+
entry = _open_session_start(journal_entries)
|
|
642
|
+
if entry is None:
|
|
643
|
+
return None
|
|
644
|
+
task_id = str(entry.get("task_id", ""))
|
|
645
|
+
role = str(entry.get("role") or "")
|
|
646
|
+
if "adapter" in entry:
|
|
647
|
+
name = str(entry.get("adapter", ""))
|
|
648
|
+
model = str(entry.get("model", ""))
|
|
649
|
+
else:
|
|
650
|
+
rebuilt = policy.adapter_policy_from_snapshot(policy_snapshot)
|
|
651
|
+
if rebuilt is None:
|
|
652
|
+
return None
|
|
653
|
+
resolved = rebuilt.resolved(role)
|
|
654
|
+
name, model = resolved.name, resolved.model
|
|
655
|
+
story_raw = entry.get("story_key")
|
|
656
|
+
story_key = str(story_raw) if story_raw else _story_key_from_task_id(task_id, role)
|
|
657
|
+
return ActiveAgent(task_id=task_id, story_key=story_key, role=role, name=name, model=model)
|
|
658
|
+
except Exception:
|
|
659
|
+
return None
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def pending_decision(journal_entries: list[dict[str, Any]]) -> tuple[str, str] | None:
|
|
663
|
+
"""(dw_id, question) when the journal's last entry is a decision-pending
|
|
664
|
+
announcement. The sweep prompter blocks on terminal input right after
|
|
665
|
+
writing it, so any subsequent entry of any kind means the prompt was
|
|
666
|
+
answered (or the sweep moved on) and the alert must clear."""
|
|
667
|
+
if not journal_entries:
|
|
668
|
+
return None
|
|
669
|
+
last = journal_entries[-1]
|
|
670
|
+
if last.get("kind") != "decision-pending":
|
|
671
|
+
return None
|
|
672
|
+
return str(last.get("dw_id", "?")), str(last.get("question", ""))
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# --------------------------------------------- project-level artifact readers
|
|
676
|
+
|
|
677
|
+
# project root -> (config.yaml sig, ProjectPaths)
|
|
678
|
+
_paths_cache: dict[Path, tuple[_StatSig, froidconfig.ProjectPaths]] = {}
|
|
679
|
+
# sprint-status.yaml path -> (sig or None for missing, parse or None)
|
|
680
|
+
_sprint_cache: dict[Path, tuple[_StatSig | None, sprintstatus.SprintStatus | None]] = {}
|
|
681
|
+
# deferred-work.md path -> (sig or None for missing, items or None)
|
|
682
|
+
_deferred_cache: dict[Path, tuple[_StatSig | None, list[DeferredItem] | None]] = {}
|
|
683
|
+
# project root -> (signature, pending decisions) — invalidated when the ledger,
|
|
684
|
+
# pre-answer store, or the set of run dirs changes
|
|
685
|
+
_missed_cache: dict[Path, tuple[Any, list]] = {}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _project_paths(project: Path) -> froidconfig.ProjectPaths | None:
|
|
689
|
+
"""FROID artifact paths, stat-gated on config.yaml; None when the project
|
|
690
|
+
is not initialized (or the config is unreadable)."""
|
|
691
|
+
project = resolve_or_lexical(project)
|
|
692
|
+
config_sig = _stat_sig(project / "_froid" / "bmm" / "config.yaml")
|
|
693
|
+
cached_paths = _paths_cache.get(project)
|
|
694
|
+
if config_sig is not None and cached_paths is not None and cached_paths[0] == config_sig:
|
|
695
|
+
return cached_paths[1]
|
|
696
|
+
try:
|
|
697
|
+
paths = froidconfig.load_paths(project)
|
|
698
|
+
except (froidconfig.FroidConfigError, OSError):
|
|
699
|
+
return None
|
|
700
|
+
if config_sig is not None:
|
|
701
|
+
_paths_cache[project] = (config_sig, paths)
|
|
702
|
+
return paths
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def sprint_overview(project: Path) -> sprintstatus.SprintStatus | None:
|
|
706
|
+
"""Parsed sprint-status.yaml, or None when unavailable (uninitialized
|
|
707
|
+
project, missing file, bad YAML). Stat-gated on both config.yaml and the
|
|
708
|
+
sprint file; the same object is returned while the file is unchanged."""
|
|
709
|
+
paths = _project_paths(project)
|
|
710
|
+
if paths is None:
|
|
711
|
+
return None
|
|
712
|
+
sprint_path = paths.sprint_status
|
|
713
|
+
sig = _stat_sig(sprint_path)
|
|
714
|
+
cached = _sprint_cache.get(sprint_path)
|
|
715
|
+
if cached is not None and cached[0] == sig:
|
|
716
|
+
return cached[1]
|
|
717
|
+
overview: sprintstatus.SprintStatus | None = None
|
|
718
|
+
if sig is not None:
|
|
719
|
+
try:
|
|
720
|
+
overview = sprintstatus.load(sprint_path)
|
|
721
|
+
except (sprintstatus.SprintStatusError, OSError):
|
|
722
|
+
overview = None
|
|
723
|
+
_sprint_cache[sprint_path] = (sig, overview)
|
|
724
|
+
return overview
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def stories_overview(project: Path, spec_folder: str) -> list[stories.StoryRow] | None:
|
|
728
|
+
"""The stories-mode board for a run's pinned spec folder: one StoryRow per
|
|
729
|
+
``stories.yaml`` entry joined with the live on-disk state of its id-keyed
|
|
730
|
+
story spec. None when unavailable (no spec folder, or a missing/invalid
|
|
731
|
+
``stories.yaml``).
|
|
732
|
+
|
|
733
|
+
Unlike :func:`sprint_overview` this is per-run (keyed by the run's
|
|
734
|
+
``spec_folder``) and is re-derived on every call rather than stat-gated: the
|
|
735
|
+
per-story spec files under ``stories/`` change independently, so there is no
|
|
736
|
+
single file to gate on. It reads a handful of small files, called on the
|
|
737
|
+
dashboard's rescan cadence."""
|
|
738
|
+
if not spec_folder:
|
|
739
|
+
return None
|
|
740
|
+
folder = stories.resolve_spec_folder(project, spec_folder)
|
|
741
|
+
try:
|
|
742
|
+
return stories.story_rows(folder)
|
|
743
|
+
except stories.StoriesError:
|
|
744
|
+
return None
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
@dataclass(frozen=True)
|
|
748
|
+
class DeferredItem:
|
|
749
|
+
id: str
|
|
750
|
+
title: str
|
|
751
|
+
status: str
|
|
752
|
+
done: bool
|
|
753
|
+
severity: str | None # normalized: critical/high/medium/low, None unknown
|
|
754
|
+
body: str
|
|
755
|
+
legacy: bool = False # pre-DW-format item (older FROID-method ledgers)
|
|
756
|
+
option_key: str | None = None # highlight-restore identity; None -> id
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def deferred_entries(project: Path) -> list[DeferredItem] | None:
|
|
760
|
+
"""All entries from deferred-work.md in file order — canonical DW entries
|
|
761
|
+
plus tolerantly-parsed legacy items — or None when unavailable
|
|
762
|
+
(uninitialized project, missing/unreadable file). Stat-gated; the same
|
|
763
|
+
list object is returned while the file is unchanged."""
|
|
764
|
+
paths = _project_paths(project)
|
|
765
|
+
if paths is None:
|
|
766
|
+
return None
|
|
767
|
+
ledger_path = paths.deferred_work
|
|
768
|
+
sig = _stat_sig(ledger_path)
|
|
769
|
+
cached = _deferred_cache.get(ledger_path)
|
|
770
|
+
if cached is not None and cached[0] == sig:
|
|
771
|
+
return cached[1]
|
|
772
|
+
items: list[DeferredItem] | None = None
|
|
773
|
+
if sig is not None:
|
|
774
|
+
try:
|
|
775
|
+
text = ledger_path.read_text(encoding="utf-8")
|
|
776
|
+
except OSError:
|
|
777
|
+
items = None
|
|
778
|
+
else:
|
|
779
|
+
merged: list[tuple[int, DeferredItem]] = []
|
|
780
|
+
for e in deferredwork.parse_ledger(text):
|
|
781
|
+
merged.append(
|
|
782
|
+
(
|
|
783
|
+
e.span[0],
|
|
784
|
+
DeferredItem(
|
|
785
|
+
id=e.id,
|
|
786
|
+
title=e.title,
|
|
787
|
+
status=e.status,
|
|
788
|
+
done=bool(e.status) and e.status.split()[0] == "done",
|
|
789
|
+
severity=deferredwork.field_severity(e.body),
|
|
790
|
+
body=e.body,
|
|
791
|
+
),
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
for n, le in enumerate(deferredwork.parse_legacy(text), start=1):
|
|
795
|
+
merged.append(
|
|
796
|
+
(
|
|
797
|
+
le.span[0],
|
|
798
|
+
DeferredItem(
|
|
799
|
+
id=le.id or f"L{n}",
|
|
800
|
+
title=le.title,
|
|
801
|
+
status="done (legacy)" if le.done else "open (legacy)",
|
|
802
|
+
done=le.done,
|
|
803
|
+
severity=le.severity,
|
|
804
|
+
body=le.body,
|
|
805
|
+
legacy=True,
|
|
806
|
+
option_key=f"legacy:{le.key}",
|
|
807
|
+
),
|
|
808
|
+
)
|
|
809
|
+
)
|
|
810
|
+
merged.sort(key=lambda pair: pair[0])
|
|
811
|
+
items = [item for _, item in merged]
|
|
812
|
+
_deferred_cache[ledger_path] = (sig, items)
|
|
813
|
+
return items
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def pending_missed_decisions(project: Path) -> list:
|
|
817
|
+
"""Deferred-work decisions earlier sweeps surfaced but no one answered (a
|
|
818
|
+
list of sweep.Decision). Cached on a signature of the ledger, the pre-answer
|
|
819
|
+
store, and the set of run dirs — a new sweep (new run dir) or an answer
|
|
820
|
+
(store/ledger change) invalidates it. Empty when the project is unavailable."""
|
|
821
|
+
from .. import decisions # lazy: pulls sweep; keep this module import-light
|
|
822
|
+
|
|
823
|
+
paths = _project_paths(project)
|
|
824
|
+
if paths is None:
|
|
825
|
+
return []
|
|
826
|
+
project = paths.project
|
|
827
|
+
sig = (
|
|
828
|
+
_stat_sig(paths.deferred_work),
|
|
829
|
+
_stat_sig(decisions.store_path(project)),
|
|
830
|
+
tuple(d.name for d in list_run_dirs(project)),
|
|
831
|
+
)
|
|
832
|
+
cached = _missed_cache.get(project)
|
|
833
|
+
if cached is not None and cached[0] == sig:
|
|
834
|
+
return cached[1]
|
|
835
|
+
try:
|
|
836
|
+
result = decisions.pending_missed_decisions(project)
|
|
837
|
+
except (froidconfig.FroidConfigError, OSError):
|
|
838
|
+
result = []
|
|
839
|
+
_missed_cache[project] = (sig, result)
|
|
840
|
+
return result
|