cursorfleet 0.1.0__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.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
cursorfleet/tui/views.py
ADDED
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
"""Pure view builders: ``FleetData`` + ``UiState`` -> rows and detail text.
|
|
2
|
+
|
|
3
|
+
No Textual here, so every screen can be asserted as plain text. All untrusted strings go
|
|
4
|
+
through :func:`~cursorfleet.tui.theme.safe` and end up in ``rich.text.Text`` (never markup).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from cursorfleet.adapters.cursor.kit_probe import HooksKitState
|
|
16
|
+
from cursorfleet.events.models import Event
|
|
17
|
+
from cursorfleet.state.artifact_scan import ArtifactRecord
|
|
18
|
+
from cursorfleet.state.event_store import EventPage
|
|
19
|
+
from cursorfleet.state.run_model import ParticipantGroup
|
|
20
|
+
from cursorfleet.tui import gates as gate_logic
|
|
21
|
+
from cursorfleet.tui.data import AgentBundle, Card, FleetData
|
|
22
|
+
from cursorfleet.tui.filters import matches_text
|
|
23
|
+
from cursorfleet.tui.theme import (
|
|
24
|
+
BASIS_TAG,
|
|
25
|
+
LANE_MARK,
|
|
26
|
+
LANE_ORDER,
|
|
27
|
+
LANE_TAG,
|
|
28
|
+
SELF_REPORTED_NOTE,
|
|
29
|
+
UNKNOWN_BUDGET,
|
|
30
|
+
Theme,
|
|
31
|
+
fmt_age,
|
|
32
|
+
fmt_duration,
|
|
33
|
+
fmt_ts,
|
|
34
|
+
plural,
|
|
35
|
+
safe,
|
|
36
|
+
short_sha,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
VIEWS: tuple[str, ...] = (
|
|
40
|
+
"active_run",
|
|
41
|
+
"active_runs",
|
|
42
|
+
"overview",
|
|
43
|
+
"timeline",
|
|
44
|
+
"worktrees",
|
|
45
|
+
"gates",
|
|
46
|
+
"evidence",
|
|
47
|
+
)
|
|
48
|
+
VIEW_TITLE = {
|
|
49
|
+
"active_run": "Active run",
|
|
50
|
+
"active_runs": "Active runs",
|
|
51
|
+
"overview": "Sessions (archive)",
|
|
52
|
+
"timeline": "Timeline",
|
|
53
|
+
"worktrees": "Worktrees",
|
|
54
|
+
"gates": "Observations",
|
|
55
|
+
"evidence": "Evidence",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
CONFIDENCE_LABEL = {
|
|
59
|
+
"explicit": "Explicit",
|
|
60
|
+
"declared": "Declared",
|
|
61
|
+
"inferred": "Inferred",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def default_view() -> str:
|
|
66
|
+
"""Primary screen for M2.5; legacy overview via ``CURSORFLEET_PROTOTYPE=overview``."""
|
|
67
|
+
mode = os.environ.get("CURSORFLEET_PROTOTYPE", "").strip().lower()
|
|
68
|
+
if mode in {"overview", "legacy", "sessions"}:
|
|
69
|
+
return "overview"
|
|
70
|
+
return "active_run"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
MAX_CARDS_PER_LANE = 100
|
|
74
|
+
MAX_LIST = 40
|
|
75
|
+
|
|
76
|
+
INIT_HINT = "cursorfleet init --cursor"
|
|
77
|
+
DOCTOR_HINT = "cursorfleet doctor"
|
|
78
|
+
GUIDANCE_NO_KIT = (
|
|
79
|
+
f"No hook telemetry yet. Run `{INIT_HINT}` to install the observe-only hooks, then use "
|
|
80
|
+
f"Cursor in this repo. `{DOCTOR_HINT}` diagnoses the setup."
|
|
81
|
+
)
|
|
82
|
+
GUIDANCE_AWAITING_CURSOR = (
|
|
83
|
+
"Hooks are installed (`cursorfleet doctor` should be ok). Open this folder in **Cursor**, "
|
|
84
|
+
"start a chat or agent session, then press `r` here — the first hook run creates telemetry."
|
|
85
|
+
)
|
|
86
|
+
GUIDANCE_KIT_PARTIAL = (
|
|
87
|
+
f"The CursorFleet kit looks incomplete (lock file or `.cursor/hooks.json` mismatch). "
|
|
88
|
+
f"Run `{INIT_HINT}` or `{DOCTOR_HINT}`."
|
|
89
|
+
)
|
|
90
|
+
HONEST_UNKNOWN = "Without telemetry the state is UNKNOWN; CursorFleet never infers inactivity."
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _timeline_empty_message(data: FleetData) -> str:
|
|
94
|
+
lines = overview_guidance_lines(data)
|
|
95
|
+
return lines[0] if lines else GUIDANCE_NO_KIT
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def overview_guidance_lines(data: FleetData) -> list[str]:
|
|
99
|
+
"""Primary setup hint when lanes are unknown (no hook sessions indexed yet)."""
|
|
100
|
+
if data.repo_root is None:
|
|
101
|
+
return [
|
|
102
|
+
"Not inside a git repository. Run from a repo or pass --path.",
|
|
103
|
+
f"Setup: `{INIT_HINT}`, check with `{DOCTOR_HINT}`.",
|
|
104
|
+
]
|
|
105
|
+
if data.telemetry != "none":
|
|
106
|
+
return []
|
|
107
|
+
if data.hooks_kit == HooksKitState.INSTALLED:
|
|
108
|
+
return [GUIDANCE_AWAITING_CURSOR, HONEST_UNKNOWN]
|
|
109
|
+
if data.hooks_kit == HooksKitState.PARTIAL:
|
|
110
|
+
return [GUIDANCE_KIT_PARTIAL, HONEST_UNKNOWN]
|
|
111
|
+
return [GUIDANCE_NO_KIT, HONEST_UNKNOWN]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Row:
|
|
116
|
+
key: str
|
|
117
|
+
label: Text
|
|
118
|
+
search: str = ""
|
|
119
|
+
header: bool = False # headers are shown but cannot be selected
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class UiState:
|
|
124
|
+
theme: Theme = field(default_factory=Theme)
|
|
125
|
+
pinned: set[str] = field(default_factory=set)
|
|
126
|
+
filter_text: str = ""
|
|
127
|
+
timeline_limit: int = 200
|
|
128
|
+
selected: dict[str, str] = field(default_factory=dict) # remembered key per view
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _t(*parts: str | tuple[str, str]) -> Text:
|
|
132
|
+
return Text.assemble(*parts)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _header(key: str, text: str, style: str = "bold") -> Row:
|
|
136
|
+
return Row(key, Text(text, style=style), header=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _note(key: str, text: str) -> Row:
|
|
140
|
+
return Row(key, Text(text, style="dim"), header=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _visible(rows: Sequence[Row], ui: UiState) -> list[Row]:
|
|
144
|
+
if not ui.filter_text.strip():
|
|
145
|
+
return list(rows)
|
|
146
|
+
keep: list[Row] = []
|
|
147
|
+
for row in rows:
|
|
148
|
+
if row.header or matches_text(row.search, ui.filter_text):
|
|
149
|
+
keep.append(row)
|
|
150
|
+
return keep
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------- overview
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _card_sort(card: Card, ui: UiState) -> tuple[int, int, float]:
|
|
157
|
+
stamp = card.last_ts.timestamp() if card.last_ts else 0.0
|
|
158
|
+
# pinned first, then blockers (most first), then recency (newest first); never raw activity
|
|
159
|
+
return (0 if card.key in ui.pinned else 1, -card.blockers, -stamp)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def card_label(card: Card, data: FleetData, ui: UiState) -> Text:
|
|
163
|
+
mark = LANE_MARK.get(card.lane, "[?]")
|
|
164
|
+
pin = "* " if card.key in ui.pinned else ""
|
|
165
|
+
basis = BASIS_TAG.get(card.basis, card.basis)
|
|
166
|
+
bundle = data.bundles.get(card.key)
|
|
167
|
+
tail: list[str] = [basis]
|
|
168
|
+
if card.kind == "agent" and card.lane == "stale_offline":
|
|
169
|
+
last = bundle.view.last_lane if bundle and bundle.view else None
|
|
170
|
+
tail.append(f"silent, was {last or '?'} (not idle)")
|
|
171
|
+
if card.kind != "worktree" or card.last_ts:
|
|
172
|
+
tail.append(
|
|
173
|
+
("declared " if card.kind == "declared" else "") + fmt_age(data.now, card.last_ts)
|
|
174
|
+
)
|
|
175
|
+
if card.blockers:
|
|
176
|
+
tail.append(
|
|
177
|
+
f"BLOCKERS:{card.blockers}" + (" (self-reported)" if card.declared_blockers else "")
|
|
178
|
+
)
|
|
179
|
+
return _t(
|
|
180
|
+
pin,
|
|
181
|
+
(mark, ui.theme.lane(card.lane)),
|
|
182
|
+
" ",
|
|
183
|
+
(safe(card.title, 40), "bold"),
|
|
184
|
+
" ",
|
|
185
|
+
(safe(card.detail, 60), "dim"),
|
|
186
|
+
" ",
|
|
187
|
+
" ".join(tail),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _group_label(group: ParticipantGroup, data: FleetData, ui: UiState) -> Text:
|
|
192
|
+
mark = LANE_MARK.get(group.summary_lane, "[?]")
|
|
193
|
+
conf = CONFIDENCE_LABEL.get(group.confidence, group.confidence)
|
|
194
|
+
tail = [conf]
|
|
195
|
+
if group.conflict:
|
|
196
|
+
tail.append("Conflict")
|
|
197
|
+
if group.declared_blockers:
|
|
198
|
+
tail.append(f"declared blockers:{group.declared_blockers}")
|
|
199
|
+
tail.append(f"{len(group.instances)} instance(s)")
|
|
200
|
+
return _t(
|
|
201
|
+
(mark, ui.theme.lane(group.summary_lane)),
|
|
202
|
+
" ",
|
|
203
|
+
(safe(group.role, 40), "bold"),
|
|
204
|
+
" ",
|
|
205
|
+
" ".join(tail),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def active_run_rows(data: FleetData, ui: UiState) -> list[Row]:
|
|
210
|
+
run = data.primary_run
|
|
211
|
+
rows: list[Row] = []
|
|
212
|
+
if run is None:
|
|
213
|
+
rows.extend(_guidance_rows(data))
|
|
214
|
+
rows.append(
|
|
215
|
+
_note(
|
|
216
|
+
"ar:empty",
|
|
217
|
+
"No active run yet. Declare a task under .cursorfleet/work/ or "
|
|
218
|
+
"`cursorfleet run start --task <slug>`.",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
return rows
|
|
222
|
+
conf = CONFIDENCE_LABEL.get(run.confidence, run.confidence)
|
|
223
|
+
rows.append(
|
|
224
|
+
Row(
|
|
225
|
+
"ar:h",
|
|
226
|
+
_t(
|
|
227
|
+
("ACTIVE RUN", "bold"),
|
|
228
|
+
f" task={safe(run.task_slug, 40)} run={safe(run.run_id, 24)} {conf}",
|
|
229
|
+
),
|
|
230
|
+
header=True,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
if run.branch or run.commit:
|
|
234
|
+
commit_label = short_sha(run.commit) if run.commit else "-"
|
|
235
|
+
rows.append(_note("ar:git", f"branch={safe(run.branch or '-', 40)} commit={commit_label}"))
|
|
236
|
+
for group in run.groups:
|
|
237
|
+
key = f"grp:{group.role}"
|
|
238
|
+
search = f"{group.role} {group.summary_lane} {group.confidence}"
|
|
239
|
+
rows.append(Row(key, _group_label(group, data, ui), search.lower()))
|
|
240
|
+
if run.suggested_session_ids:
|
|
241
|
+
rows.append(Row("ar:sug:h", _t(("Suggested sessions (not attached)", "bold")), header=True))
|
|
242
|
+
for sid in run.suggested_session_ids[:MAX_LIST]:
|
|
243
|
+
hint = f" attach: cursorfleet run attach <run> --session {sid}"
|
|
244
|
+
rows.append(_note(f"sug:{sid}", hint))
|
|
245
|
+
if run.unassociated_session_ids:
|
|
246
|
+
rows.append(Row("ar:un:h", _t(("Other live sessions (unscoped)", "bold")), header=True))
|
|
247
|
+
for sid in run.unassociated_session_ids[:MAX_LIST]:
|
|
248
|
+
rows.append(_note(f"un:{sid}", f" session {safe(sid, 48)}"))
|
|
249
|
+
rows.append(_note("ar:nav", "Press u for all runs; o for full session archive."))
|
|
250
|
+
return rows
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def active_runs_rows(data: FleetData, ui: UiState) -> list[Row]:
|
|
254
|
+
rows: list[Row] = [Row("runs:h", _t(("ACTIVE RUNS", "bold")), header=True)]
|
|
255
|
+
if not data.run_snapshots:
|
|
256
|
+
rows.append(_note("runs:0", "No persisted runs. `cursorfleet run start --task <slug>`"))
|
|
257
|
+
if data.primary_run is not None:
|
|
258
|
+
run = data.primary_run
|
|
259
|
+
conf = CONFIDENCE_LABEL.get(run.confidence, run.confidence)
|
|
260
|
+
rows.append(
|
|
261
|
+
Row(
|
|
262
|
+
f"run:{run.run_id}",
|
|
263
|
+
_t(("inferred", "dim"), f" {safe(run.task_slug, 40)} ({conf})"),
|
|
264
|
+
run.task_slug,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
return rows
|
|
268
|
+
for run in data.run_snapshots:
|
|
269
|
+
conf = CONFIDENCE_LABEL.get(run.confidence, run.confidence)
|
|
270
|
+
rows.append(
|
|
271
|
+
Row(
|
|
272
|
+
f"run:{run.run_id}",
|
|
273
|
+
_t(
|
|
274
|
+
(safe(run.task_slug, 40), "bold"),
|
|
275
|
+
f" {safe(run.run_id, 24)} {conf} groups={len(run.groups)}",
|
|
276
|
+
),
|
|
277
|
+
run.task_slug,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
archived = sum(1 for r in data.run_records if r.archived)
|
|
281
|
+
if archived:
|
|
282
|
+
rows.append(_note("runs:arch", f"{archived} archived run(s); see `cursorfleet run list`"))
|
|
283
|
+
return rows
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def active_run_detail(data: FleetData, ui: UiState, key: str) -> Text:
|
|
287
|
+
run = data.primary_run
|
|
288
|
+
if run is None or not key.startswith("grp:"):
|
|
289
|
+
return Text("Select a role group to see instances.", style="dim")
|
|
290
|
+
role = key[4:]
|
|
291
|
+
group = next((g for g in run.groups if g.role == role), None)
|
|
292
|
+
if group is None:
|
|
293
|
+
return Text("Unknown group.", style="dim")
|
|
294
|
+
doc = _Doc(ui.theme)
|
|
295
|
+
doc.title(f"Role: {safe(role, 40)}", "bold")
|
|
296
|
+
doc.line("Summary lane", LANE_TAG.get(group.summary_lane, group.summary_lane))
|
|
297
|
+
doc.line("Confidence", CONFIDENCE_LABEL.get(group.confidence, group.confidence))
|
|
298
|
+
if group.conflict:
|
|
299
|
+
doc.raw("Conflict: instances disagree on lane or state.", "bold")
|
|
300
|
+
doc.raw("")
|
|
301
|
+
for inst in group.instances:
|
|
302
|
+
lane = LANE_TAG.get(inst.lane, inst.lane)
|
|
303
|
+
basis = BASIS_TAG.get(inst.lane_basis, inst.lane_basis)
|
|
304
|
+
doc.line(f"{inst.session_id}/{inst.agent_key}", f"{lane} ({basis})")
|
|
305
|
+
doc.line(" last", fmt_ts(inst.last_ts) if inst.last_ts else "-")
|
|
306
|
+
return doc.text
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def overview_rows(data: FleetData, ui: UiState) -> list[Row]:
|
|
310
|
+
by_lane: dict[str, list[Card]] = {lane: [] for lane in LANE_ORDER}
|
|
311
|
+
for card in data.cards:
|
|
312
|
+
by_lane.setdefault(card.lane if card.lane in by_lane else "unknown", []).append(card)
|
|
313
|
+
rows: list[Row] = []
|
|
314
|
+
for lane in LANE_ORDER:
|
|
315
|
+
cards = sorted(by_lane[lane], key=lambda c: _card_sort(c, ui))
|
|
316
|
+
shown: list[Row] = []
|
|
317
|
+
for card in cards[:MAX_CARDS_PER_LANE]:
|
|
318
|
+
search = f"{card.title} {card.detail} {LANE_TAG[lane]} {card.basis} {card.kind}"
|
|
319
|
+
shown.append(Row(card.key, card_label(card, data, ui), search.lower()))
|
|
320
|
+
visible = _visible(shown, ui)
|
|
321
|
+
count = f"{len(visible)}" if not ui.filter_text.strip() else f"{len(visible)}/{len(cards)}"
|
|
322
|
+
rows.append(
|
|
323
|
+
Row(
|
|
324
|
+
f"lane:{lane}",
|
|
325
|
+
_t(
|
|
326
|
+
(
|
|
327
|
+
f"{LANE_MARK[lane]} {LANE_TAG[lane]} ({count})",
|
|
328
|
+
ui.theme.lane(lane) or "bold",
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
header=True,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
rows.extend(visible)
|
|
335
|
+
if len(cards) > MAX_CARDS_PER_LANE:
|
|
336
|
+
rows.append(
|
|
337
|
+
_note(f"more:{lane}", f" +{len(cards) - MAX_CARDS_PER_LANE} older (filter with /)")
|
|
338
|
+
)
|
|
339
|
+
if lane == "unknown":
|
|
340
|
+
rows.extend(_guidance_rows(data))
|
|
341
|
+
return rows
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _guidance_rows(data: FleetData) -> list[Row]:
|
|
345
|
+
lines = overview_guidance_lines(data)
|
|
346
|
+
return [_note(f"g:{index}", line) for index, line in enumerate(lines)]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _declared_lines(records: Sequence[ArtifactRecord]) -> dict[str, list[ArtifactRecord]]:
|
|
350
|
+
out: dict[str, list[ArtifactRecord]] = {}
|
|
351
|
+
for record in sorted(records, key=lambda r: (r.created, r.path)):
|
|
352
|
+
out.setdefault(record.kind, []).append(record)
|
|
353
|
+
return out
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class _Doc:
|
|
357
|
+
"""Tiny builder for the detail pane."""
|
|
358
|
+
|
|
359
|
+
def __init__(self, theme: Theme) -> None:
|
|
360
|
+
self.theme = theme
|
|
361
|
+
self.text = Text()
|
|
362
|
+
|
|
363
|
+
def title(self, text: str, style: str = "") -> None:
|
|
364
|
+
self.text.append(text + "\n", style=style or self.theme.accent)
|
|
365
|
+
|
|
366
|
+
def line(self, label: str, value: str, style: str = "") -> None:
|
|
367
|
+
self.text.append(f"{label:<13}", style="dim")
|
|
368
|
+
self.text.append(value + "\n", style=style)
|
|
369
|
+
|
|
370
|
+
def raw(self, value: str, style: str = "") -> None:
|
|
371
|
+
self.text.append(value + "\n", style=style)
|
|
372
|
+
|
|
373
|
+
def blank(self) -> None:
|
|
374
|
+
self.text.append("\n")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def agent_detail(
|
|
378
|
+
data: FleetData, ui: UiState, key: str, tools: Sequence[Event] | None = None
|
|
379
|
+
) -> Text:
|
|
380
|
+
bundle = data.bundles.get(key)
|
|
381
|
+
if bundle is None:
|
|
382
|
+
return Text("Select an agent to see its detail.", style="dim")
|
|
383
|
+
doc = _Doc(ui.theme)
|
|
384
|
+
acc, view = bundle.acc, bundle.view
|
|
385
|
+
declared_only = view is None
|
|
386
|
+
lane = view.lane if view else acc.lane.value
|
|
387
|
+
title = safe(acc.role or ("main agent" if acc.key == "main" else acc.key), 60)
|
|
388
|
+
doc.title(f"AGENT {title}")
|
|
389
|
+
doc.line(
|
|
390
|
+
"Lane",
|
|
391
|
+
f"{LANE_MARK.get(lane, '[?]')} {LANE_TAG.get(lane, lane)}",
|
|
392
|
+
ui.theme.lane(lane),
|
|
393
|
+
)
|
|
394
|
+
if view is not None:
|
|
395
|
+
basis = BASIS_TAG.get(view.lane_basis, view.lane_basis)
|
|
396
|
+
doc.line("Lane basis", f"{basis} (heuristic over events; {view.attribution} attribution)")
|
|
397
|
+
if view.stale:
|
|
398
|
+
doc.line(
|
|
399
|
+
"Silent",
|
|
400
|
+
f"no live event for {fmt_age(data.now, bundle.acc.last_live_ts)}; "
|
|
401
|
+
f"last lane was {view.last_lane or '?'}. Silence is not idleness.",
|
|
402
|
+
"bold",
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
doc.line("Lane basis", "SELF-REPORTED by artifact only; no hook telemetry for this role")
|
|
406
|
+
doc.line("Role", safe(acc.role or "-"))
|
|
407
|
+
if bundle.session is not None and not declared_only:
|
|
408
|
+
doc.line("Session", safe(bundle.session.session_id, 80))
|
|
409
|
+
doc.line("Agent key", safe(acc.key, 80))
|
|
410
|
+
if acc.instance_id:
|
|
411
|
+
doc.line("Instance", safe(acc.instance_id, 80))
|
|
412
|
+
_task_lines(doc, bundle)
|
|
413
|
+
_worktree_lines(doc, data, bundle)
|
|
414
|
+
if view is not None:
|
|
415
|
+
_activity_lines(doc, data, bundle, tools)
|
|
416
|
+
_gate_lines(doc, data, bundle)
|
|
417
|
+
doc.line("Token/cost", UNKNOWN_BUDGET)
|
|
418
|
+
_declared_lines_into(doc, bundle)
|
|
419
|
+
return doc.text
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _task_lines(doc: _Doc, bundle: AgentBundle) -> None:
|
|
423
|
+
records = bundle.records
|
|
424
|
+
tasks = sorted({r.task for r in records})
|
|
425
|
+
if tasks:
|
|
426
|
+
doc.line(
|
|
427
|
+
"Task", f"{', '.join(safe(t, 40) for t in tasks[:4])} [SELF-REPORTED, by role name]"
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
doc.line("Task", "unknown (no artifact declares one for this role)")
|
|
431
|
+
observed = list(bundle.acc.issue_refs)
|
|
432
|
+
declared = sorted({r.issue_ref for r in records if r.issue_ref})
|
|
433
|
+
refs = [f"{safe(r, 40)} (observed)" for r in observed]
|
|
434
|
+
refs += [f"{safe(r, 40)} (SELF-REPORTED)" for r in declared if r not in observed]
|
|
435
|
+
doc.line("Issues", ", ".join(refs[:6]) if refs else "none known")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _worktree_lines(doc: _Doc, data: FleetData, bundle: AgentBundle) -> None:
|
|
439
|
+
acc = bundle.acc
|
|
440
|
+
wt = data.worktree_by_id(acc.worktree_id)
|
|
441
|
+
if wt is not None:
|
|
442
|
+
branch = wt.branch or ("detached" if wt.detached else "?")
|
|
443
|
+
dirty = "?" if wt.dirty_count is None else str(wt.dirty_count)
|
|
444
|
+
doc.line("Worktree", safe(wt.path, 100))
|
|
445
|
+
doc.line("Branch", f"{safe(branch, 60)} HEAD {short_sha(wt.head)} dirty files: {dirty}")
|
|
446
|
+
elif acc.branch or acc.commit or acc.worktree_id:
|
|
447
|
+
doc.line("Worktree", f"{safe(acc.worktree_id or '?')} (not listed by git now)")
|
|
448
|
+
doc.line(
|
|
449
|
+
"Branch",
|
|
450
|
+
f"{safe(acc.branch or '?', 60)} commit {short_sha(acc.commit)} (as last seen)",
|
|
451
|
+
)
|
|
452
|
+
else:
|
|
453
|
+
doc.line("Worktree", "unknown")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _activity_lines(
|
|
457
|
+
doc: _Doc, data: FleetData, bundle: AgentBundle, tools: Sequence[Event] | None
|
|
458
|
+
) -> None:
|
|
459
|
+
acc, view = bundle.acc, bundle.view
|
|
460
|
+
assert view is not None # noqa: S101
|
|
461
|
+
more = "+" if acc.files_overflow else ""
|
|
462
|
+
doc.line("Files", f"{len(acc.files)}{more} changed (observed)")
|
|
463
|
+
if tools:
|
|
464
|
+
names = [safe(e.tool_name or e.kind.value, 24) for e in tools]
|
|
465
|
+
doc.line("Last tools", ", ".join(names))
|
|
466
|
+
else:
|
|
467
|
+
doc.line("Last tools", safe(acc.last_tool or "none observed", 40))
|
|
468
|
+
if acc.last_test is not None:
|
|
469
|
+
doc.line(
|
|
470
|
+
"Tests",
|
|
471
|
+
f"last run {acc.last_test.outcome.value} "
|
|
472
|
+
f"{fmt_age(data.now, acc.last_test.ts)} (observed)",
|
|
473
|
+
)
|
|
474
|
+
else:
|
|
475
|
+
doc.line("Tests", "no test run observed")
|
|
476
|
+
fails = f" ({view.tool_failures} failed)" if view.tool_failures else ""
|
|
477
|
+
doc.line("Elapsed", fmt_duration(view.elapsed_s))
|
|
478
|
+
doc.line("Tool calls", f"{view.tool_call_count}{fails}")
|
|
479
|
+
ctx = (
|
|
480
|
+
f", context {view.last_context_usage_percent:.0f}%"
|
|
481
|
+
if view.last_context_usage_percent is not None
|
|
482
|
+
else ""
|
|
483
|
+
)
|
|
484
|
+
doc.line("Compactions", f"{view.compactions}{ctx}")
|
|
485
|
+
if view.stop_report is not None:
|
|
486
|
+
sr = view.stop_report
|
|
487
|
+
calls = sr.tool_call_count if sr.tool_call_count is not None else "?"
|
|
488
|
+
doc.line(
|
|
489
|
+
"Stop report",
|
|
490
|
+
f"outcome {sr.outcome or '?'}, {calls} tool calls (reported by Cursor)",
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _gate_lines(doc: _Doc, data: FleetData, bundle: AgentBundle) -> None:
|
|
495
|
+
rows = data.gates.by_worktree.get(bundle.acc.worktree_id)
|
|
496
|
+
if rows is None:
|
|
497
|
+
doc.line("Gates", "unknown (no evidence for this worktree), display-only")
|
|
498
|
+
return
|
|
499
|
+
doc.line("Gates", "this worktree, display-only, independent signals:")
|
|
500
|
+
for row in rows:
|
|
501
|
+
doc.raw(f" {row.label}: {row.state.upper()}", doc.theme.gate(row.state))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _declared_lines_into(doc: _Doc, bundle: AgentBundle) -> None:
|
|
505
|
+
records = bundle.records
|
|
506
|
+
if not records:
|
|
507
|
+
doc.line("Declared", "no artifacts for this role")
|
|
508
|
+
return
|
|
509
|
+
doc.blank()
|
|
510
|
+
doc.title(f"DECLARED BY THE AGENT - {SELF_REPORTED_NOTE}", "bold")
|
|
511
|
+
groups = _declared_lines(records)
|
|
512
|
+
for kind, label in (
|
|
513
|
+
("context.loaded", "Context sources"),
|
|
514
|
+
("plan.created", "Plans"),
|
|
515
|
+
("handoff.created", "Handoffs"),
|
|
516
|
+
("blocker.raised", "Blockers"),
|
|
517
|
+
):
|
|
518
|
+
items = groups.get(kind, [])
|
|
519
|
+
if not items:
|
|
520
|
+
continue
|
|
521
|
+
if kind == "context.loaded":
|
|
522
|
+
refs = sorted({ref for r in items for ref in r.context_refs})
|
|
523
|
+
shown = ", ".join(safe(r, 50) for r in refs[:8]) + (
|
|
524
|
+
f" (+{len(refs) - 8} more)" if len(refs) > 8 else ""
|
|
525
|
+
)
|
|
526
|
+
doc.line(label, f"{len(refs)} path(s): {shown or '-'}")
|
|
527
|
+
elif kind == "handoff.created":
|
|
528
|
+
last = items[-1]
|
|
529
|
+
dest = safe(last.to_role or "unspecified", 40)
|
|
530
|
+
doc.line(label, f"{len(items)}; latest destination: {dest}")
|
|
531
|
+
else:
|
|
532
|
+
latest = items[-1]
|
|
533
|
+
doc.line(
|
|
534
|
+
label,
|
|
535
|
+
f"{len(items)}; latest {fmt_ts(latest.created)} UTC task {safe(latest.task, 40)}",
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ---------------------------------------------------------------------------- timeline
|
|
540
|
+
|
|
541
|
+
_SRC = {"observed": "obs", "self_reported": "SELF", "derived": "drv"}
|
|
542
|
+
_ATTR = {"exact": "exact", "inferred": "infer", "unknown": "unk"}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def event_summary(event: Event) -> str:
|
|
546
|
+
bits: list[str] = []
|
|
547
|
+
if event.tool_name:
|
|
548
|
+
bits.append(safe(event.tool_name, 30))
|
|
549
|
+
if event.command is not None:
|
|
550
|
+
shown = event.command.display or event.command.argv0
|
|
551
|
+
bits.append(safe(shown, 70))
|
|
552
|
+
if event.command.exit_code is not None:
|
|
553
|
+
bits.append(f"exit {event.command.exit_code}")
|
|
554
|
+
if event.paths:
|
|
555
|
+
bits.append(
|
|
556
|
+
safe(event.paths[0].path, 60)
|
|
557
|
+
+ (f" +{len(event.paths) - 1}" if len(event.paths) > 1 else "")
|
|
558
|
+
)
|
|
559
|
+
if event.outcome is not None:
|
|
560
|
+
bits.append(event.outcome.value)
|
|
561
|
+
if event.gate is not None:
|
|
562
|
+
bits.append(f"{event.gate.name}={event.gate.state.value}")
|
|
563
|
+
if event.issue_ref:
|
|
564
|
+
bits.append(safe(event.issue_ref, 30))
|
|
565
|
+
return " ".join(bits)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def event_label(event: Event, ui: UiState) -> Text:
|
|
569
|
+
risk = f" RISK:{event.risk.value}" if event.risk.value != "none" else ""
|
|
570
|
+
src = _SRC.get(event.source.value, event.source.value)
|
|
571
|
+
who = safe(event.agent_id or "main/unknown", 28)
|
|
572
|
+
return _t(
|
|
573
|
+
(fmt_ts(event.ts), "dim"),
|
|
574
|
+
" ",
|
|
575
|
+
(f"{event.kind.value:<17}", "bold"),
|
|
576
|
+
f" {src}/{_ATTR.get(event.attribution.value, '?')} ",
|
|
577
|
+
(who, ""),
|
|
578
|
+
" ",
|
|
579
|
+
event_summary(event),
|
|
580
|
+
(risk, "bold" if risk else ""),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def timeline_rows(data: FleetData, ui: UiState, page: EventPage | None = None) -> list[Row]:
|
|
585
|
+
page = page or data.timeline
|
|
586
|
+
rows: list[Row] = []
|
|
587
|
+
shown = len(page.events)
|
|
588
|
+
note = f"Showing {shown} of {page.total} matching event(s), newest first. Times are UTC."
|
|
589
|
+
rows.append(_header("tl:head", note))
|
|
590
|
+
if page.total > shown:
|
|
591
|
+
rows.append(_note("tl:more", "Press m to load more."))
|
|
592
|
+
for err in data.timeline_errors:
|
|
593
|
+
rows.append(_note(f"tl:err:{err}", f"filter problem: {err}"))
|
|
594
|
+
if page.error:
|
|
595
|
+
rows.append(_note("tl:error", page.error))
|
|
596
|
+
if page.skipped:
|
|
597
|
+
rows.append(
|
|
598
|
+
_note(
|
|
599
|
+
"tl:skipped", f"{page.skipped} stored event(s) no longer validate and were skipped"
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
if not page.events:
|
|
603
|
+
if page.total == 0 and ui.filter_text:
|
|
604
|
+
empty_msg = "No events match."
|
|
605
|
+
else:
|
|
606
|
+
empty_msg = _timeline_empty_message(data)
|
|
607
|
+
rows.append(_note("tl:empty", empty_msg))
|
|
608
|
+
for event in page.events:
|
|
609
|
+
rows.append(Row(f"ev:{event.session_id}:{event.event_id}", event_label(event, ui)))
|
|
610
|
+
return rows
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _attr_explain(event: Event) -> str:
|
|
614
|
+
return {
|
|
615
|
+
"exact": "identity came in the Cursor payload itself",
|
|
616
|
+
"inferred": "linked by tool_use_id or worktree (PROVISIONAL)",
|
|
617
|
+
"unknown": "Cursor did not say which agent (PROVISIONAL, ADR 0001 Q1)",
|
|
618
|
+
}.get(event.attribution.value, "")
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def event_detail(event: Event | None, ui: UiState) -> Text:
|
|
622
|
+
if event is None:
|
|
623
|
+
return Text("Select an event to see its sanitized fields.", style="dim")
|
|
624
|
+
doc = _Doc(ui.theme)
|
|
625
|
+
doc.title(f"EVENT {event.kind.value}")
|
|
626
|
+
src = event.source.value
|
|
627
|
+
note = " [SELF-REPORTED: a claim, not evidence]" if src == "self_reported" else ""
|
|
628
|
+
doc.line("Source", f"{src}{note}")
|
|
629
|
+
doc.line("Attribution", f"{event.attribution.value}: {_attr_explain(event)}")
|
|
630
|
+
doc.line("Time", f"{fmt_ts(event.ts)} UTC")
|
|
631
|
+
doc.line("Producer", f"{event.producer.value} {safe(event.producer_version, 30)}")
|
|
632
|
+
doc.line("Risk", f"{event.risk.value} (informational only; v0.1 never acts on it)")
|
|
633
|
+
doc.line("Session", safe(event.session_id, 100))
|
|
634
|
+
doc.line("Agent", safe(event.agent_id or "none (main agent or unattributed)", 100))
|
|
635
|
+
for label, value in (
|
|
636
|
+
("Worktree", event.worktree_id),
|
|
637
|
+
("Branch", event.branch),
|
|
638
|
+
("Commit", event.commit),
|
|
639
|
+
("Issue", event.issue_ref),
|
|
640
|
+
("Tool", event.tool_name),
|
|
641
|
+
("Hook", event.hook),
|
|
642
|
+
("Outcome", event.outcome.value if event.outcome else None),
|
|
643
|
+
("Status", event.status.value if event.status else None),
|
|
644
|
+
):
|
|
645
|
+
if value:
|
|
646
|
+
doc.line(label, safe(value, 100))
|
|
647
|
+
if event.gate is not None:
|
|
648
|
+
doc.line("Gate", f"{event.gate.name} = {event.gate.state.value}")
|
|
649
|
+
if event.command is not None:
|
|
650
|
+
cmd = event.command
|
|
651
|
+
doc.line(
|
|
652
|
+
"Command",
|
|
653
|
+
safe(cmd.display or cmd.argv0, 200)
|
|
654
|
+
+ (" (truncated)" if cmd.display_truncated else ""),
|
|
655
|
+
)
|
|
656
|
+
extra = [f"argv0 {safe(cmd.argv0, 40)}"]
|
|
657
|
+
if cmd.subcommand:
|
|
658
|
+
extra.append(f"sub {safe(cmd.subcommand, 40)}")
|
|
659
|
+
if cmd.exit_code is not None:
|
|
660
|
+
extra.append(f"exit {cmd.exit_code}")
|
|
661
|
+
if cmd.duration_ms is not None:
|
|
662
|
+
extra.append(f"{cmd.duration_ms} ms")
|
|
663
|
+
doc.line("", " ".join(extra))
|
|
664
|
+
if event.paths:
|
|
665
|
+
doc.line("Paths", plural(len(event.paths), "path") + " (workspace-relative):")
|
|
666
|
+
for item in event.paths[:MAX_LIST]:
|
|
667
|
+
doc.raw(f" {safe(item.path, 120)} ({item.op.value})")
|
|
668
|
+
if len(event.paths) > MAX_LIST:
|
|
669
|
+
doc.raw(f" ... +{len(event.paths) - MAX_LIST} more")
|
|
670
|
+
if event.metrics is not None:
|
|
671
|
+
parts = [f"{k}={v}" for k, v in event.metrics.model_dump(exclude_none=True).items()]
|
|
672
|
+
doc.line("Metrics", ", ".join(parts))
|
|
673
|
+
return doc.text
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# ---------------------------------------------------------------------------- worktrees
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def worktree_rows(data: FleetData, ui: UiState) -> list[Row]:
|
|
680
|
+
rows: list[Row] = []
|
|
681
|
+
if data.git is None:
|
|
682
|
+
reason = "git collection is disabled (--no-git)" if not data.git_enabled else "no data yet"
|
|
683
|
+
return [_header("wt:none", f"No worktree data: {reason}.")]
|
|
684
|
+
if not data.git.available:
|
|
685
|
+
return [
|
|
686
|
+
_header("wt:down", f"git data unavailable: {safe(data.git.error, 100)}"),
|
|
687
|
+
_note("wt:hint", f"Run from inside a git repository, then `{DOCTOR_HINT}`."),
|
|
688
|
+
]
|
|
689
|
+
rows.append(
|
|
690
|
+
_header(
|
|
691
|
+
"wt:head",
|
|
692
|
+
f"{plural(len(data.git.worktrees), 'worktree')} (read-only; r re-reads, "
|
|
693
|
+
"CursorFleet never fetches, so ahead/behind can lag the remote).",
|
|
694
|
+
)
|
|
695
|
+
)
|
|
696
|
+
for index, wt in enumerate(data.git.worktrees):
|
|
697
|
+
branch = wt.branch or ("detached" if wt.detached else "?")
|
|
698
|
+
dirty = "dirty ?" if wt.dirty_count is None else f"dirty {wt.dirty_count}"
|
|
699
|
+
sync = "no upstream" if wt.ahead is None else f"ahead {wt.ahead}/behind {wt.behind}"
|
|
700
|
+
marks = [f"STALE({','.join(wt.stale_reasons)})"] if wt.stale else []
|
|
701
|
+
owners = _owner_names(data, wt.worktree_id)
|
|
702
|
+
label = _t(
|
|
703
|
+
("[main] " if wt.is_main else "[wt] ", "bold"),
|
|
704
|
+
(safe(branch, 40), "bold"),
|
|
705
|
+
f" HEAD {short_sha(wt.head)} {dirty} {sync} ",
|
|
706
|
+
(" ".join(marks), "bold" if marks else ""),
|
|
707
|
+
f" owner: {owners}" if owners else " owner: unknown (no telemetry)",
|
|
708
|
+
f" last commit {fmt_age(data.now, wt.last_commit_ts)}",
|
|
709
|
+
)
|
|
710
|
+
search = f"{wt.path} {branch} {' '.join(marks)} {owners}".lower()
|
|
711
|
+
rows.append(Row(f"wt:{index}:{wt.path}", label, search))
|
|
712
|
+
return [rows[0], *_visible(rows[1:], ui)]
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _owner_names(data: FleetData, worktree_id: str | None) -> str:
|
|
716
|
+
if worktree_id is None:
|
|
717
|
+
return ""
|
|
718
|
+
names: list[str] = []
|
|
719
|
+
for bundle in data.bundles.values():
|
|
720
|
+
if bundle.acc.worktree_id == worktree_id and bundle.view is not None:
|
|
721
|
+
names.append(safe(bundle.acc.role or bundle.acc.key, 30))
|
|
722
|
+
return ", ".join(sorted(set(names)))
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def worktree_detail(data: FleetData, ui: UiState, key: str) -> Text:
|
|
726
|
+
if data.git is None:
|
|
727
|
+
return Text("No worktree data.", style="dim")
|
|
728
|
+
wt = next((w for i, w in enumerate(data.git.worktrees) if key == f"wt:{i}:{w.path}"), None)
|
|
729
|
+
if wt is None:
|
|
730
|
+
return Text("Select a worktree.", style="dim")
|
|
731
|
+
doc = _Doc(ui.theme)
|
|
732
|
+
doc.title(f"WORKTREE {'(main) ' if wt.is_main else ''}{safe(_base_name(wt.path), 60)}")
|
|
733
|
+
doc.line("Path", safe(wt.path, 200))
|
|
734
|
+
doc.line("Branch", safe(wt.branch or ("detached HEAD" if wt.detached else "?"), 100))
|
|
735
|
+
doc.line("HEAD", wt.head or "-")
|
|
736
|
+
dirty = "unknown" if wt.dirty_count is None else f"{wt.dirty_count} file(s)"
|
|
737
|
+
doc.line("Dirty", dirty)
|
|
738
|
+
if wt.ahead is None:
|
|
739
|
+
doc.line("Ahead/behind", "unknown (no upstream, detached HEAD or unreadable ref)")
|
|
740
|
+
else:
|
|
741
|
+
doc.line(
|
|
742
|
+
"Ahead/behind", f"+{wt.ahead} / -{wt.behind} vs the existing upstream ref (no fetch)"
|
|
743
|
+
)
|
|
744
|
+
doc.line(
|
|
745
|
+
"State",
|
|
746
|
+
", ".join(
|
|
747
|
+
s
|
|
748
|
+
for s, on in (
|
|
749
|
+
("locked", wt.locked),
|
|
750
|
+
("prunable", wt.prunable),
|
|
751
|
+
("bare", wt.bare),
|
|
752
|
+
("missing", not wt.exists),
|
|
753
|
+
)
|
|
754
|
+
if on
|
|
755
|
+
)
|
|
756
|
+
or "normal",
|
|
757
|
+
)
|
|
758
|
+
doc.line("Stale", ", ".join(wt.stale_reasons) if wt.stale else "no")
|
|
759
|
+
owners = _owner_names(data, wt.worktree_id)
|
|
760
|
+
doc.line("Owner", owners or "unknown (no hook telemetry links an agent to this worktree)")
|
|
761
|
+
doc.line(
|
|
762
|
+
"Last commit", f"{fmt_ts(wt.last_commit_ts)} UTC ({fmt_age(data.now, wt.last_commit_ts)})"
|
|
763
|
+
)
|
|
764
|
+
if wt.error:
|
|
765
|
+
doc.line("Problem", safe(wt.error, 100))
|
|
766
|
+
doc.blank()
|
|
767
|
+
doc.raw("Cursor creates and deletes worktrees itself; this list is a snapshot.", "dim")
|
|
768
|
+
return doc.text
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _base_name(path: str) -> str:
|
|
772
|
+
return path.rstrip("/\\").rsplit("/", 1)[-1].rsplit("\\", 1)[-1] or path
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# ---------------------------------------------------------------------------- gates
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _gate_board(data: FleetData) -> list[tuple[str | None, str, list[gate_logic.GateStatus]]]:
|
|
779
|
+
"""``(worktree_id, title, statuses)`` for each worktree, plus unlinked evidence."""
|
|
780
|
+
boards: list[tuple[str | None, str, list[gate_logic.GateStatus]]] = []
|
|
781
|
+
seen: set[str | None] = set()
|
|
782
|
+
if data.git is not None:
|
|
783
|
+
for wt in data.git.worktrees:
|
|
784
|
+
statuses = data.gates.by_worktree.get(wt.worktree_id)
|
|
785
|
+
if statuses is None:
|
|
786
|
+
statuses = gate_logic.evaluate([], worktree_id=wt.worktree_id, head=wt.head)
|
|
787
|
+
branch = wt.branch or ("detached" if wt.detached else "?")
|
|
788
|
+
boards.append((wt.worktree_id, f"{safe(branch, 40)} @ {short_sha(wt.head)}", statuses))
|
|
789
|
+
seen.add(wt.worktree_id)
|
|
790
|
+
for wt_id, statuses in data.gates.by_worktree.items():
|
|
791
|
+
if wt_id not in seen:
|
|
792
|
+
title = f"worktree {safe(wt_id or 'unlinked', 30)} (not listed by git; HEAD unknown)"
|
|
793
|
+
boards.append((wt_id, title, statuses))
|
|
794
|
+
if not boards:
|
|
795
|
+
boards.append(
|
|
796
|
+
(
|
|
797
|
+
None,
|
|
798
|
+
"no git data (gates cannot be bound to a commit)",
|
|
799
|
+
gate_logic.evaluate([], worktree_id=None, head=None),
|
|
800
|
+
)
|
|
801
|
+
)
|
|
802
|
+
return boards
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def gate_rows(data: FleetData, ui: UiState) -> list[Row]:
|
|
806
|
+
rows: list[Row] = [
|
|
807
|
+
_header(
|
|
808
|
+
"gt:head", f"GATES - {gate_logic.DISPLAY_ONLY}. Independent signals; no overall score."
|
|
809
|
+
),
|
|
810
|
+
_note(
|
|
811
|
+
"gt:rule",
|
|
812
|
+
"Evidence = observed test/lint/type runs bound to a commit. "
|
|
813
|
+
"Self-reported claims never count.",
|
|
814
|
+
),
|
|
815
|
+
]
|
|
816
|
+
for wt_id, title, statuses in _gate_board(data):
|
|
817
|
+
rows.append(_header(f"gt:wt:{wt_id}", f"== {title}"))
|
|
818
|
+
for status in statuses:
|
|
819
|
+
label = _t(
|
|
820
|
+
(f"[{status.state.upper():<7}]", ui.theme.gate(status.state)),
|
|
821
|
+
f" {status.label:<19} ",
|
|
822
|
+
(safe(status.detail, 80), "dim"),
|
|
823
|
+
)
|
|
824
|
+
rows.append(
|
|
825
|
+
Row(
|
|
826
|
+
f"gt:{wt_id}:{status.gate}",
|
|
827
|
+
label,
|
|
828
|
+
f"{status.label} {status.state} {title}".lower(),
|
|
829
|
+
)
|
|
830
|
+
)
|
|
831
|
+
return [r for r in _visible(rows, ui)]
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def gate_detail(data: FleetData, ui: UiState, key: str) -> Text:
|
|
835
|
+
for wt_id, title, statuses in _gate_board(data):
|
|
836
|
+
for status in statuses:
|
|
837
|
+
if key != f"gt:{wt_id}:{status.gate}":
|
|
838
|
+
continue
|
|
839
|
+
doc = _Doc(ui.theme)
|
|
840
|
+
doc.title(f"GATE {status.label}")
|
|
841
|
+
doc.line("State", status.state.upper(), ui.theme.gate(status.state))
|
|
842
|
+
doc.line("Detail", status.detail)
|
|
843
|
+
doc.line("Worktree", title)
|
|
844
|
+
doc.line("HEAD", status.head or "unknown")
|
|
845
|
+
item = status.evidence
|
|
846
|
+
if item is None:
|
|
847
|
+
doc.line(
|
|
848
|
+
"Evidence",
|
|
849
|
+
"none. In v0.1 only observed test/lint/type runs and "
|
|
850
|
+
"gate.changed events count.",
|
|
851
|
+
)
|
|
852
|
+
else:
|
|
853
|
+
doc.line("Evidence", f"{item.origin} (observed/derived from Cursor hooks)")
|
|
854
|
+
doc.line("Result", item.state)
|
|
855
|
+
doc.line("Commit", item.commit or "not recorded")
|
|
856
|
+
doc.line("When", f"{fmt_ts(item.ts)} UTC ({fmt_age(data.now, item.ts)})")
|
|
857
|
+
doc.line("Agent", safe(item.agent or "unattributed", 60))
|
|
858
|
+
if item.command:
|
|
859
|
+
doc.line("Command", safe(item.command, 160))
|
|
860
|
+
if item.note:
|
|
861
|
+
doc.line("Note", item.note)
|
|
862
|
+
doc.blank()
|
|
863
|
+
doc.raw(gate_logic.DISPLAY_ONLY + ".", "bold")
|
|
864
|
+
return doc.text
|
|
865
|
+
return Text("Select a gate.", style="dim")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# ---------------------------------------------------------------------------- evidence
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def evidence_rows(data: FleetData, ui: UiState) -> list[Row]:
|
|
872
|
+
rows: list[Row] = [
|
|
873
|
+
_header("ev:h1", "OBSERVED / DERIVED EVIDENCE (test, lint and type runs; gate events)")
|
|
874
|
+
]
|
|
875
|
+
latest = sorted(data.gates.evidence, key=lambda e: (e.ts, e.session_id), reverse=True)
|
|
876
|
+
if not latest:
|
|
877
|
+
rows.append(_note("ev:none", "none yet: no verify-style command has been observed."))
|
|
878
|
+
for index, item in enumerate(latest[:MAX_LIST]):
|
|
879
|
+
label = _t(
|
|
880
|
+
(f"[{item.state.upper():<7}]", ui.theme.gate(item.state)),
|
|
881
|
+
f" {gate_logic.GATE_LABEL[item.gate]:<19} @{short_sha(item.commit)} ",
|
|
882
|
+
(fmt_age(data.now, item.ts), "dim"),
|
|
883
|
+
f" {safe(item.command or item.origin, 60)}",
|
|
884
|
+
)
|
|
885
|
+
rows.append(
|
|
886
|
+
Row(f"evi:{index}", label, f"{item.gate} {item.state} {item.command or ''}".lower())
|
|
887
|
+
)
|
|
888
|
+
rows.append(_header("ev:h2", f"DECLARED BY AGENTS - {SELF_REPORTED_NOTE}"))
|
|
889
|
+
records = sorted(data.artifacts.records, key=lambda r: (r.created, r.path), reverse=True)
|
|
890
|
+
if not records:
|
|
891
|
+
rows.append(_note("ev:noart", "no valid artifacts under the work directory."))
|
|
892
|
+
for index, rec in enumerate(records[:MAX_LIST]):
|
|
893
|
+
label = _t(
|
|
894
|
+
("[SELF]", "bold"),
|
|
895
|
+
f" {rec.kind:<16} {safe(rec.author_role, 24)}",
|
|
896
|
+
f" -> {safe(rec.to_role, 20)}" if rec.to_role else "",
|
|
897
|
+
f" task {safe(rec.task, 30)} ",
|
|
898
|
+
(fmt_age(data.now, rec.created), "dim"),
|
|
899
|
+
)
|
|
900
|
+
rows.append(
|
|
901
|
+
Row(
|
|
902
|
+
f"art:{index}",
|
|
903
|
+
label,
|
|
904
|
+
f"{rec.kind} {rec.author_role} {rec.task} {rec.to_role or ''}".lower(),
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
if data.artifacts.issues:
|
|
908
|
+
rows.append(
|
|
909
|
+
_header("ev:h3", "INVALID ARTIFACTS (validation problems; not shown as declarations)")
|
|
910
|
+
)
|
|
911
|
+
for index, issue in enumerate(data.artifacts.issues[:MAX_LIST]):
|
|
912
|
+
rows.append(
|
|
913
|
+
Row(
|
|
914
|
+
f"iss:{index}",
|
|
915
|
+
_t(("[INVALID]", "bold"), f" {safe(issue.path, 60)}: {issue.message}"),
|
|
916
|
+
f"{issue.path} {issue.code}".lower(),
|
|
917
|
+
)
|
|
918
|
+
)
|
|
919
|
+
return _visible(rows, ui)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def evidence_detail(data: FleetData, ui: UiState, key: str) -> Text:
|
|
923
|
+
doc = _Doc(ui.theme)
|
|
924
|
+
if key.startswith("evi:"):
|
|
925
|
+
latest = sorted(data.gates.evidence, key=lambda e: (e.ts, e.session_id), reverse=True)
|
|
926
|
+
index = int(key[4:])
|
|
927
|
+
if index < len(latest):
|
|
928
|
+
item = latest[index]
|
|
929
|
+
doc.title(f"EVIDENCE {gate_logic.GATE_LABEL[item.gate]}")
|
|
930
|
+
doc.line("Result", item.state.upper(), ui.theme.gate(item.state))
|
|
931
|
+
doc.line("Origin", f"{item.origin} (observed/derived from Cursor hooks)")
|
|
932
|
+
doc.line("Commit", item.commit or "not recorded (evidence cannot be bound)")
|
|
933
|
+
doc.line("Session", safe(item.session_id, 80))
|
|
934
|
+
doc.line("Agent", safe(item.agent or "unattributed", 60))
|
|
935
|
+
doc.line("When", f"{fmt_ts(item.ts)} UTC")
|
|
936
|
+
if item.command:
|
|
937
|
+
doc.line("Command", safe(item.command, 160))
|
|
938
|
+
if item.note:
|
|
939
|
+
doc.line("Note", item.note)
|
|
940
|
+
return doc.text
|
|
941
|
+
if key.startswith("art:"):
|
|
942
|
+
records = sorted(data.artifacts.records, key=lambda r: (r.created, r.path), reverse=True)
|
|
943
|
+
index = int(key[4:])
|
|
944
|
+
if index < len(records):
|
|
945
|
+
rec = records[index]
|
|
946
|
+
doc.title(f"DECLARATION {rec.kind}", "bold")
|
|
947
|
+
doc.raw(SELF_REPORTED_NOTE, "bold")
|
|
948
|
+
doc.line("Task", safe(rec.task, 60))
|
|
949
|
+
doc.line("Author role", f"{safe(rec.author_role, 40)} (claimed; can be forged)")
|
|
950
|
+
doc.line("To role", safe(rec.to_role or "-", 40))
|
|
951
|
+
doc.line("Issue", safe(rec.issue_ref or "-", 60))
|
|
952
|
+
doc.line("Created", f"{fmt_ts(rec.created)} UTC")
|
|
953
|
+
doc.line("File", safe(rec.path, 100))
|
|
954
|
+
doc.line("Context", f"{len(rec.context_refs)} path(s)")
|
|
955
|
+
for ref in rec.context_refs[:MAX_LIST]:
|
|
956
|
+
doc.raw(f" {safe(ref, 100)}")
|
|
957
|
+
return doc.text
|
|
958
|
+
if key.startswith("iss:"):
|
|
959
|
+
index = int(key[4:])
|
|
960
|
+
if index < len(data.artifacts.issues):
|
|
961
|
+
issue = data.artifacts.issues[index]
|
|
962
|
+
doc.title("INVALID ARTIFACT", "bold")
|
|
963
|
+
doc.line("File", safe(issue.path, 100))
|
|
964
|
+
doc.line("Problem", issue.message)
|
|
965
|
+
doc.line("Code", issue.code)
|
|
966
|
+
doc.raw(
|
|
967
|
+
"Fix the file and run `cursorfleet validate`. The file's content is never shown.",
|
|
968
|
+
"dim",
|
|
969
|
+
)
|
|
970
|
+
return doc.text
|
|
971
|
+
return Text("Select an item to see its detail.", style="dim")
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
# ---------------------------------------------------------------------------- dispatch
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def rows_for(view: str, data: FleetData, ui: UiState) -> list[Row]: # noqa: PLR0911
|
|
978
|
+
if view == "active_run":
|
|
979
|
+
return active_run_rows(data, ui)
|
|
980
|
+
if view == "active_runs":
|
|
981
|
+
return active_runs_rows(data, ui)
|
|
982
|
+
if view == "timeline":
|
|
983
|
+
return timeline_rows(data, ui)
|
|
984
|
+
if view == "worktrees":
|
|
985
|
+
return worktree_rows(data, ui)
|
|
986
|
+
if view == "gates":
|
|
987
|
+
return gate_rows(data, ui)
|
|
988
|
+
if view == "evidence":
|
|
989
|
+
return evidence_rows(data, ui)
|
|
990
|
+
return overview_rows(data, ui)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def detail_for( # noqa: PLR0911 - one return per view, clearer than a table
|
|
994
|
+
view: str,
|
|
995
|
+
data: FleetData,
|
|
996
|
+
ui: UiState,
|
|
997
|
+
key: str | None,
|
|
998
|
+
tools: Sequence[Event] | None = None,
|
|
999
|
+
) -> Text:
|
|
1000
|
+
if key is None:
|
|
1001
|
+
return Text("Nothing selected.", style="dim")
|
|
1002
|
+
if view == "active_run":
|
|
1003
|
+
return active_run_detail(data, ui, key)
|
|
1004
|
+
if view == "active_runs" and key.startswith("run:"):
|
|
1005
|
+
run_id = key[4:]
|
|
1006
|
+
run = next(
|
|
1007
|
+
(r for r in data.run_snapshots if r.run_id == run_id),
|
|
1008
|
+
data.primary_run if data.primary_run and data.primary_run.run_id == run_id else None,
|
|
1009
|
+
)
|
|
1010
|
+
if run is None:
|
|
1011
|
+
return Text("Select a run.", style="dim")
|
|
1012
|
+
doc = _Doc(ui.theme)
|
|
1013
|
+
doc.title(f"Run {safe(run.run_id, 40)}", "bold")
|
|
1014
|
+
doc.line("Task", safe(run.task_slug, 60))
|
|
1015
|
+
doc.line("Confidence", CONFIDENCE_LABEL.get(run.confidence, run.confidence))
|
|
1016
|
+
doc.line("Groups", str(len(run.groups)))
|
|
1017
|
+
return doc.text
|
|
1018
|
+
if view == "timeline":
|
|
1019
|
+
found = next(
|
|
1020
|
+
(e for e in data.timeline.events if f"ev:{e.session_id}:{e.event_id}" == key), None
|
|
1021
|
+
)
|
|
1022
|
+
return event_detail(found, ui)
|
|
1023
|
+
if view == "worktrees":
|
|
1024
|
+
return worktree_detail(data, ui, key)
|
|
1025
|
+
if view == "gates":
|
|
1026
|
+
return gate_detail(data, ui, key)
|
|
1027
|
+
if view == "evidence":
|
|
1028
|
+
return evidence_detail(data, ui, key)
|
|
1029
|
+
return (
|
|
1030
|
+
agent_detail(data, ui, key, tools)
|
|
1031
|
+
if not key.startswith("wt:")
|
|
1032
|
+
else _wt_card_detail(data, ui, key)
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _wt_card_detail(data: FleetData, ui: UiState, key: str) -> Text:
|
|
1037
|
+
"""Detail for a git-only worktree card in the overview (no telemetry)."""
|
|
1038
|
+
if data.git is None:
|
|
1039
|
+
return Text("No data.", style="dim")
|
|
1040
|
+
for index, wt in enumerate(data.git.worktrees):
|
|
1041
|
+
if key == f"wt:{wt.path}":
|
|
1042
|
+
text = worktree_detail(data, ui, f"wt:{index}:{wt.path}")
|
|
1043
|
+
text.append("\nNo hook telemetry: lane is UNKNOWN, never idle.\n", style="bold")
|
|
1044
|
+
for line in overview_guidance_lines(data):
|
|
1045
|
+
text.append(line + "\n", style="dim")
|
|
1046
|
+
return text
|
|
1047
|
+
return Text("Select an item.", style="dim")
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def recent_events_text(data: FleetData, ui: UiState, count: int = 12) -> Text:
|
|
1051
|
+
"""Tile content: the newest events, no selection."""
|
|
1052
|
+
text = Text("RECENT EVENTS\n", style=ui.theme.accent)
|
|
1053
|
+
if not data.recent.events:
|
|
1054
|
+
text.append("none yet\n", style="dim")
|
|
1055
|
+
for event in data.recent.events[:count]:
|
|
1056
|
+
text.append_text(event_label(event, ui))
|
|
1057
|
+
text.append("\n")
|
|
1058
|
+
return text
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def side_summary_text(data: FleetData, ui: UiState) -> Text:
|
|
1062
|
+
"""Tile content: gate state per worktree (no aggregate) and worktree facts."""
|
|
1063
|
+
text = Text("GATES + WORKTREES (display-only)\n", style=ui.theme.accent)
|
|
1064
|
+
for _wt, title, statuses in _gate_board(data)[:4]:
|
|
1065
|
+
text.append(f"{title}\n", style="bold")
|
|
1066
|
+
text.append(
|
|
1067
|
+
" " + " ".join(f"{s.label.split()[0]}:{s.state.upper()}" for s in statuses) + "\n"
|
|
1068
|
+
)
|
|
1069
|
+
return text
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def card_for_key(data: FleetData, key: str) -> Card | None:
|
|
1073
|
+
return next((c for c in data.cards if c.key == key), None)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def first_selectable(rows: Sequence[Row]) -> str | None:
|
|
1077
|
+
return next((r.key for r in rows if not r.header), None)
|