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.
Files changed (101) hide show
  1. cursorfleet/__init__.py +9 -0
  2. cursorfleet/_templates/cursor/agents/architect.md +18 -0
  3. cursorfleet/_templates/cursor/agents/implementer.md +20 -0
  4. cursorfleet/_templates/cursor/agents/patcher.md +17 -0
  5. cursorfleet/_templates/cursor/agents/qa.md +21 -0
  6. cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
  7. cursorfleet/_templates/cursor/agents/scout.md +17 -0
  8. cursorfleet/_templates/cursor/agents/tester.md +20 -0
  9. cursorfleet/_templates/cursor/agents-md/root.md +7 -0
  10. cursorfleet/_templates/cursor/agents-md/work.md +7 -0
  11. cursorfleet/_templates/cursor/config/config.toml +47 -0
  12. cursorfleet/_templates/cursor/hooks/entry.json +4 -0
  13. cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
  14. cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
  15. cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
  16. cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
  17. cursorfleet/adapters/__init__.py +0 -0
  18. cursorfleet/adapters/cursor/__init__.py +0 -0
  19. cursorfleet/adapters/cursor/blocks.py +104 -0
  20. cursorfleet/adapters/cursor/diagnostics.py +547 -0
  21. cursorfleet/adapters/cursor/fsutil.py +156 -0
  22. cursorfleet/adapters/cursor/hook_main.py +428 -0
  23. cursorfleet/adapters/cursor/hook_normalize.py +527 -0
  24. cursorfleet/adapters/cursor/hook_policy.py +76 -0
  25. cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
  26. cursorfleet/adapters/cursor/hooksjson.py +305 -0
  27. cursorfleet/adapters/cursor/installer.py +626 -0
  28. cursorfleet/adapters/cursor/kit.py +210 -0
  29. cursorfleet/adapters/cursor/kit_probe.py +54 -0
  30. cursorfleet/adapters/cursor/lock.py +145 -0
  31. cursorfleet/adapters/cursor/templates.py +60 -0
  32. cursorfleet/adapters/cursor/validation.py +365 -0
  33. cursorfleet/adapters/cursor/workspace.py +80 -0
  34. cursorfleet/cli/__init__.py +0 -0
  35. cursorfleet/cli/commands/__init__.py +1 -0
  36. cursorfleet/cli/commands/_common.py +82 -0
  37. cursorfleet/cli/commands/_resolution.py +56 -0
  38. cursorfleet/cli/commands/doctor.py +81 -0
  39. cursorfleet/cli/commands/events.py +197 -0
  40. cursorfleet/cli/commands/index.py +55 -0
  41. cursorfleet/cli/commands/init.py +71 -0
  42. cursorfleet/cli/commands/migrate.py +43 -0
  43. cursorfleet/cli/commands/replay.py +76 -0
  44. cursorfleet/cli/commands/run.py +97 -0
  45. cursorfleet/cli/commands/status.py +210 -0
  46. cursorfleet/cli/commands/tui.py +47 -0
  47. cursorfleet/cli/commands/uninstall.py +73 -0
  48. cursorfleet/cli/commands/validate.py +69 -0
  49. cursorfleet/cli/main.py +81 -0
  50. cursorfleet/config/__init__.py +44 -0
  51. cursorfleet/config/io.py +27 -0
  52. cursorfleet/config/models.py +154 -0
  53. cursorfleet/config/roster.py +245 -0
  54. cursorfleet/events/__init__.py +39 -0
  55. cursorfleet/events/forbidden.py +45 -0
  56. cursorfleet/events/ids.py +109 -0
  57. cursorfleet/events/kinds.py +132 -0
  58. cursorfleet/events/models.py +213 -0
  59. cursorfleet/events/pathcheck.py +45 -0
  60. cursorfleet/git/__init__.py +0 -0
  61. cursorfleet/git/collector.py +226 -0
  62. cursorfleet/git/runner.py +135 -0
  63. cursorfleet/git/worktrees.py +71 -0
  64. cursorfleet/policy/__init__.py +0 -0
  65. cursorfleet/py.typed +0 -0
  66. cursorfleet/safeexe.py +70 -0
  67. cursorfleet/schema_gen.py +73 -0
  68. cursorfleet/state/__init__.py +0 -0
  69. cursorfleet/state/artifact_scan.py +309 -0
  70. cursorfleet/state/context.py +38 -0
  71. cursorfleet/state/event_store.py +227 -0
  72. cursorfleet/state/indexer.py +443 -0
  73. cursorfleet/state/lock.py +74 -0
  74. cursorfleet/state/models.py +268 -0
  75. cursorfleet/state/reducer.py +588 -0
  76. cursorfleet/state/retention.py +218 -0
  77. cursorfleet/state/run_builder.py +136 -0
  78. cursorfleet/state/run_model.py +43 -0
  79. cursorfleet/state/run_store.py +95 -0
  80. cursorfleet/state/runtime.py +645 -0
  81. cursorfleet/state/spool.py +186 -0
  82. cursorfleet/state/spool_read.py +242 -0
  83. cursorfleet/state/status_doc.py +293 -0
  84. cursorfleet/tui/__init__.py +0 -0
  85. cursorfleet/tui/app.py +581 -0
  86. cursorfleet/tui/data.py +545 -0
  87. cursorfleet/tui/filters.py +82 -0
  88. cursorfleet/tui/gates.py +255 -0
  89. cursorfleet/tui/help.py +101 -0
  90. cursorfleet/tui/layout.py +26 -0
  91. cursorfleet/tui/theme.py +176 -0
  92. cursorfleet/tui/views.py +1077 -0
  93. cursorfleet/tui/watch.py +33 -0
  94. cursorfleet/workflow/__init__.py +0 -0
  95. cursorfleet/workflow/artifacts.py +148 -0
  96. cursorfleet/workflow/frontmatter.py +232 -0
  97. cursorfleet-0.1.0.dist-info/METADATA +161 -0
  98. cursorfleet-0.1.0.dist-info/RECORD +101 -0
  99. cursorfleet-0.1.0.dist-info/WHEEL +4 -0
  100. cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
  101. cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,545 @@
1
+ """Data layer for the TUI: everything the screens show, gathered without Textual.
2
+
3
+ Sources (all read-only, no network):
4
+
5
+ - the SQLite projection, kept fresh by tailing the spool with the single-writer indexer
6
+ (:class:`~cursorfleet.state.indexer.Indexer`); events for the timeline come from the
7
+ projection's ``events`` table;
8
+ - the read-only git collector (never fetches, never mutates);
9
+ - the artifact scanner over ``.cursorfleet/work`` (SELF-REPORTED declarations).
10
+
11
+ :meth:`DataSource.load` is blocking and meant for a worker thread. It never raises: every
12
+ failure becomes a line in :attr:`FleetData.problems` so a corrupt database, a damaged spool
13
+ or a missing repository degrades the display instead of crashing it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass, field
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+
24
+ from cursorfleet.adapters.cursor.kit_probe import HooksKitState, probe_hooks_kit
25
+ from cursorfleet.config.io import loads_config
26
+ from cursorfleet.events.kinds import EventKind
27
+ from cursorfleet.events.models import Event
28
+ from cursorfleet.git.collector import (
29
+ DEFAULT_STALE_AFTER_HOURS,
30
+ GitSnapshot,
31
+ WorktreeSnapshot,
32
+ collect,
33
+ )
34
+ from cursorfleet.git.runner import GitError
35
+ from cursorfleet.state.artifact_scan import (
36
+ DEFAULT_WORK_DIR,
37
+ ArtifactRecord,
38
+ ArtifactScan,
39
+ ArtifactScanner,
40
+ )
41
+ from cursorfleet.state.context import RepoContext, resolve_repo
42
+ from cursorfleet.state.event_store import (
43
+ EventFilter,
44
+ EventPage,
45
+ matches_filter,
46
+ query_events,
47
+ )
48
+ from cursorfleet.state.indexer import Indexer, replay_session_state
49
+ from cursorfleet.state.models import AgentAcc, AgentView, FleetView, Lane, SessionAcc, SessionView
50
+ from cursorfleet.state.reducer import DEFAULT_STALE_AFTER_S, MAIN, reduce_events, snapshot
51
+ from cursorfleet.state.run_builder import build_active_run, pick_primary_task
52
+ from cursorfleet.state.run_model import RunSnapshot
53
+ from cursorfleet.state.run_store import RunRecord, list_runs
54
+ from cursorfleet.state.spool_read import Corruption, list_spool_files
55
+ from cursorfleet.state.status_doc import worktree_activity
56
+ from cursorfleet.tui import gates as gate_logic
57
+
58
+ MAX_WORKTREE_ROOTS = 25
59
+ RECENT_EVENTS = 40
60
+ GATE_EVIDENCE_EVENTS = 400
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Card:
65
+ """One item in the workflow overview."""
66
+
67
+ key: str
68
+ kind: str # "agent" | "declared" | "worktree"
69
+ lane: str
70
+ title: str
71
+ detail: str
72
+ basis: str # observed_activity | lifecycle_only | self_reported | derived | none
73
+ last_ts: datetime | None
74
+ blockers: int = 0
75
+ declared_blockers: int = 0
76
+ stale: bool = False
77
+ session_id: str | None = None
78
+ agent_key: str | None = None
79
+ worktree_path: str | None = None
80
+
81
+
82
+ @dataclass
83
+ class AgentBundle:
84
+ """Everything known about one agent card (a hook-observed agent or a declared role)."""
85
+
86
+ key: str
87
+ session: SessionAcc | None
88
+ acc: AgentAcc
89
+ view: AgentView | None # None for declared-only roles (no live telemetry)
90
+ session_view: SessionView | None
91
+ records: list[ArtifactRecord] = field(default_factory=list) # self-reported, by role
92
+
93
+ @property
94
+ def role(self) -> str | None:
95
+ return self.acc.role
96
+
97
+
98
+ @dataclass
99
+ class GateBoard:
100
+ """Gate statuses per worktree id (``None`` = evidence tied to no known worktree)."""
101
+
102
+ by_worktree: dict[str | None, list[gate_logic.GateStatus]] = field(default_factory=dict)
103
+ evidence: list[gate_logic.GateEvidence] = field(default_factory=list)
104
+
105
+
106
+ @dataclass
107
+ class FleetData:
108
+ now: datetime
109
+ repo_root: str | None = None
110
+ runtime_dir: str | None = None
111
+ db_path: str | None = None
112
+ sessions: dict[str, SessionAcc] = field(default_factory=dict)
113
+ fleet: FleetView | None = None
114
+ git: GitSnapshot | None = None
115
+ git_enabled: bool = True
116
+ artifacts: ArtifactScan = field(default_factory=ArtifactScan)
117
+ cards: list[Card] = field(default_factory=list)
118
+ bundles: dict[str, AgentBundle] = field(default_factory=dict)
119
+ gates: GateBoard = field(default_factory=GateBoard)
120
+ corruption: Corruption = field(default_factory=Corruption)
121
+ spool_files: int = 0
122
+ telemetry: str = "none" # "hooks" | "none"
123
+ hooks_kit: HooksKitState = HooksKitState.MISSING
124
+ problems: list[str] = field(default_factory=list)
125
+ recent: EventPage = field(default_factory=EventPage)
126
+ timeline: EventPage = field(default_factory=EventPage)
127
+ timeline_errors: tuple[str, ...] = ()
128
+ primary_run: RunSnapshot | None = None
129
+ run_snapshots: list[RunSnapshot] = field(default_factory=list)
130
+ run_records: list[RunRecord] = field(default_factory=list)
131
+
132
+ @property
133
+ def is_empty(self) -> bool:
134
+ return not self.cards and not self.artifacts.records
135
+
136
+ def worktree_by_id(self, worktree_id: str | None) -> WorktreeSnapshot | None:
137
+ if self.git is None or worktree_id is None:
138
+ return None
139
+ return next((w for w in self.git.worktrees if w.worktree_id == worktree_id), None)
140
+
141
+ def blocked_count(self) -> int:
142
+ return sum(1 for c in self.cards if c.blockers > 0)
143
+
144
+
145
+ def _work_dir(top_level: str | None) -> str:
146
+ """The configured work dir (``.cursorfleet/config.toml``), or the default."""
147
+ if top_level is None:
148
+ return DEFAULT_WORK_DIR
149
+ config = Path(top_level, ".cursorfleet", "config.toml")
150
+ try:
151
+ if config.is_file() and config.stat().st_size < 256 * 1024:
152
+ return loads_config(config.read_text(encoding="utf-8")).work.dir
153
+ except (OSError, ValueError):
154
+ pass
155
+ return DEFAULT_WORK_DIR
156
+
157
+
158
+ def _owners(snap: WorktreeSnapshot, fleet: FleetView) -> list[str]:
159
+ out: list[str] = []
160
+ for session in fleet.sessions:
161
+ for agent in session.agents:
162
+ if snap.worktree_id and agent.worktree_id == snap.worktree_id:
163
+ out.append(f"{session.session_id}/{agent.key}")
164
+ return sorted(out)
165
+
166
+
167
+ class DataSource:
168
+ """Stateful loader (caches the projection, git snapshot and artifact scan)."""
169
+
170
+ def __init__(
171
+ self,
172
+ repo: str | os.PathLike[str],
173
+ *,
174
+ with_git: bool = True,
175
+ clock: Callable[[], datetime] | None = None,
176
+ stale_after_s: int = DEFAULT_STALE_AFTER_S,
177
+ git_every: int = 5,
178
+ ) -> None:
179
+ self._repo = os.fspath(repo)
180
+ self._with_git = with_git
181
+ self._clock = clock or (lambda: datetime.now(UTC))
182
+ self._stale_after_s = stale_after_s
183
+ self._git_every = max(1, git_every)
184
+ self._ctx: RepoContext | None = None
185
+ self._ctx_error: str | None = None
186
+ self._indexer: Indexer | None = None
187
+ self._sessions: dict[str, SessionAcc] = {}
188
+ self._corruption = Corruption()
189
+ self._git: GitSnapshot | None = None
190
+ self._ticks = 0
191
+ self._loaded = False
192
+ self._scanner: ArtifactScanner | None = None
193
+ self._evidence: list[gate_logic.GateEvidence] = []
194
+ self._evidence_dirty = True
195
+
196
+ # ------------------------------------------------------------------ public
197
+
198
+ def now(self) -> datetime:
199
+ return self._clock().astimezone(UTC)
200
+
201
+ def load(
202
+ self,
203
+ flt: EventFilter | None = None,
204
+ *,
205
+ limit: int = 200,
206
+ force_git: bool = False,
207
+ ) -> FleetData:
208
+ """Blocking snapshot for the screens. Never raises."""
209
+ now = self.now()
210
+ data = FleetData(now=now, git_enabled=self._with_git)
211
+ try:
212
+ self._load(data, flt, limit, force_git)
213
+ except Exception as exc:
214
+ data.problems.append(f"data load failed ({type(exc).__name__}); showing what is known")
215
+ return data
216
+
217
+ def recent_tools(self, session_id: str, agent_key: str, limit: int = 6) -> list[Event]:
218
+ """Newest tool events for one agent, for the detail pane."""
219
+ if self._ctx is None:
220
+ return []
221
+ page = query_events(
222
+ self._ctx.paths.db, EventFilter(session=session_id, kind="tool.started"), limit=200
223
+ )
224
+ wanted = None if agent_key == MAIN else agent_key
225
+ return [e for e in page.events if e.agent_id == wanted][:limit]
226
+
227
+ # ------------------------------------------------------------------ internals
228
+
229
+ def _resolve(self) -> RepoContext | None:
230
+ if self._ctx is not None:
231
+ return self._ctx
232
+ try:
233
+ self._ctx = resolve_repo(self._repo)
234
+ except GitError as exc:
235
+ self._ctx_error = type(exc).__name__
236
+ return None
237
+ self._indexer = Indexer(self._ctx.paths)
238
+ return self._ctx
239
+
240
+ def _load(self, data: FleetData, flt: EventFilter | None, limit: int, force_git: bool) -> None:
241
+ ctx = self._resolve()
242
+ if ctx is None:
243
+ data.problems.append("not a git repository (or git is unavailable)")
244
+ data.fleet = snapshot({}, now=data.now, stale_after_s=self._stale_after_s)
245
+ return
246
+ data.repo_root = ctx.top_level or ctx.common_dir
247
+ data.runtime_dir = ctx.paths.root
248
+ data.db_path = ctx.paths.db
249
+ data.hooks_kit = probe_hooks_kit(data.repo_root).state
250
+ self._refresh_projection(data)
251
+ data.sessions = self._sessions
252
+ data.corruption = self._corruption
253
+ data.telemetry = "hooks" if self._sessions else "none"
254
+ fleet = snapshot(self._sessions, now=data.now, stale_after_s=self._stale_after_s)
255
+ data.fleet = fleet
256
+ self._refresh_git(data, force_git)
257
+ self._scan_artifacts(data, ctx)
258
+ data.recent = query_events(ctx.paths.db, limit=RECENT_EVENTS)
259
+ data.timeline = query_events(ctx.paths.db, flt, limit=limit)
260
+ self._merge_artifact_events(data, flt, limit)
261
+ self._refresh_evidence(data)
262
+ _build_cards(data)
263
+ self._build_gates(data)
264
+ self._build_runs(data, ctx)
265
+ self._add_problems(data)
266
+
267
+ def _build_runs(self, data: FleetData, ctx: RepoContext) -> None:
268
+ if data.fleet is None:
269
+ return
270
+ session_views = {s.session_id: s for s in data.fleet.sessions}
271
+ records = data.artifacts.records
272
+ all_records = list_runs(ctx.paths.root)
273
+ data.run_records = all_records
274
+ active_meta = sorted(
275
+ (r for r in all_records if not r.archived),
276
+ key=lambda r: r.created_at,
277
+ reverse=True,
278
+ )
279
+ snapshots: list[RunSnapshot] = []
280
+ for meta in active_meta:
281
+ snapshots.append(
282
+ build_active_run(
283
+ task_slug=meta.task_slug,
284
+ run_id=meta.run_id,
285
+ sessions=data.sessions,
286
+ session_views=session_views,
287
+ records=records,
288
+ linked_session_ids=frozenset(meta.session_ids),
289
+ now=data.now,
290
+ stale_after_s=self._stale_after_s,
291
+ )
292
+ )
293
+ data.run_snapshots = snapshots
294
+ if snapshots:
295
+ data.primary_run = snapshots[0]
296
+ return
297
+ task = pick_primary_task(records, now=data.now)
298
+ if task is None and not session_views:
299
+ data.primary_run = None
300
+ return
301
+ slug = task or "unscoped"
302
+ data.primary_run = build_active_run(
303
+ task_slug=slug,
304
+ run_id=f"inferred:{slug}",
305
+ sessions=data.sessions,
306
+ session_views=session_views,
307
+ records=records,
308
+ linked_session_ids=frozenset(),
309
+ now=data.now,
310
+ stale_after_s=self._stale_after_s,
311
+ )
312
+
313
+ def _refresh_projection(self, data: FleetData) -> None:
314
+ assert self._indexer is not None # noqa: S101 - set together with the context
315
+ assert self._ctx is not None # noqa: S101
316
+ try:
317
+ data.spool_files = len(list_spool_files(self._ctx.paths))
318
+ except OSError:
319
+ data.spool_files = 0
320
+ if data.spool_files == 0 and not os.path.exists(self._ctx.paths.db):
321
+ return
322
+ try:
323
+ stats = self._indexer.try_sync()
324
+ except Exception as exc:
325
+ data.problems.append(
326
+ f"indexing failed ({type(exc).__name__}); showing last known state"
327
+ )
328
+ stats = None
329
+ changed = (
330
+ not self._loaded
331
+ or stats is None
332
+ or stats.events_new > 0
333
+ or stats.sessions_pruned > 0
334
+ or stats.db_recovered
335
+ )
336
+ if stats is not None and stats.db_recovered:
337
+ data.problems.append("the projection was damaged: it was moved aside and rebuilt")
338
+ if not changed:
339
+ return
340
+ sessions = self._indexer.load_sessions()
341
+ corruption = self._indexer.corruption()
342
+ if not sessions and data.spool_files:
343
+ try:
344
+ sessions, corruption, _n = replay_session_state(self._ctx.paths)
345
+ except Exception as exc:
346
+ data.problems.append(f"spool replay failed ({type(exc).__name__})")
347
+ self._sessions = sessions
348
+ self._corruption = corruption
349
+ self._loaded = True
350
+ self._evidence_dirty = True
351
+
352
+ def _refresh_git(self, data: FleetData, force: bool) -> None:
353
+ if not self._with_git:
354
+ return
355
+ due = force or self._git is None or self._ticks % self._git_every == 0
356
+ self._ticks += 1
357
+ if due and data.repo_root is not None:
358
+ self._git = collect(
359
+ data.repo_root,
360
+ now=data.now,
361
+ stale_after_hours=DEFAULT_STALE_AFTER_HOURS,
362
+ activity=worktree_activity(self._sessions),
363
+ )
364
+ self._evidence_dirty = True
365
+ data.git = self._git
366
+
367
+ def _scan_artifacts(self, data: FleetData, ctx: RepoContext) -> None:
368
+ if self._scanner is None:
369
+ self._scanner = ArtifactScanner(_work_dir(ctx.top_level))
370
+ roots: list[str] = [ctx.top_level] if ctx.top_level else []
371
+ if data.git is not None:
372
+ for wt in data.git.worktrees:
373
+ if wt.real_path and wt.exists and not wt.bare:
374
+ roots.append(wt.real_path)
375
+ data.artifacts = self._scanner.scan(roots[:MAX_WORKTREE_ROOTS])
376
+
377
+ @staticmethod
378
+ def _merge_artifact_events(data: FleetData, flt: EventFilter | None, limit: int) -> None:
379
+ """Self-reported artifact events are not in the database; merge them by time."""
380
+ extra = [e for e in data.artifacts.events if matches_filter(e, flt or EventFilter())]
381
+ if not extra:
382
+ return
383
+ merged = sorted(
384
+ [*data.timeline.events, *extra], key=lambda e: (e.ts, e.event_id), reverse=True
385
+ )
386
+ data.timeline.events = merged[:limit]
387
+ data.timeline.total += len(extra)
388
+
389
+ def _refresh_evidence(self, data: FleetData) -> None:
390
+ if not self._evidence_dirty or self._ctx is None:
391
+ return
392
+ events: list[Event] = []
393
+ for kind in (
394
+ EventKind.VERIFICATION_OBSERVED,
395
+ EventKind.TEST_COMPLETED,
396
+ EventKind.GATE_CHANGED,
397
+ ):
398
+ page = query_events(
399
+ self._ctx.paths.db, EventFilter(kind=kind.value), limit=GATE_EVIDENCE_EVENTS
400
+ )
401
+ events.extend(page.events)
402
+ self._evidence = gate_logic.evidence_from_events(events)
403
+ self._evidence_dirty = False
404
+
405
+ def _build_gates(self, data: FleetData) -> None:
406
+ heads: dict[str | None, str | None] = {}
407
+ if data.git is not None:
408
+ for wt in data.git.worktrees:
409
+ if wt.worktree_id is not None:
410
+ heads[wt.worktree_id] = wt.head
411
+ # Evidence for worktrees git does not list (deleted, or git disabled) is judged with
412
+ # an unknown HEAD, which keeps it "unknown" rather than pretending it is current.
413
+ for item in self._evidence:
414
+ heads.setdefault(item.worktree_id, None)
415
+ board = GateBoard(evidence=list(self._evidence))
416
+ board.by_worktree = gate_logic.evaluate_all(self._evidence, heads)
417
+ data.gates = board
418
+
419
+ def _add_problems(self, data: FleetData) -> None:
420
+ if data.corruption.total:
421
+ data.problems.append(
422
+ f"{data.corruption.total} corrupt or skipped spool line(s) were ignored"
423
+ )
424
+ if data.artifacts.issues:
425
+ data.problems.append(
426
+ f"{len(data.artifacts.issues)} invalid artifact file(s): see the evidence view"
427
+ )
428
+ if data.git_enabled and data.git is not None and not data.git.available:
429
+ data.problems.append(f"git data unavailable: {data.git.error}")
430
+ if data.timeline.error and (data.spool_files or data.sessions):
431
+ data.problems.append(data.timeline.error)
432
+
433
+
434
+ # ---------------------------------------------------------------- cards
435
+
436
+
437
+ def _declared_for_role(records: list[ArtifactRecord], role: str) -> list[ArtifactRecord]:
438
+ """Task-scoped declared records for a hook-observed role (avoid cross-task bleed)."""
439
+ matched = [r for r in records if r.author_role == role]
440
+ tasks = {r.task for r in matched}
441
+ if len(tasks) == 1:
442
+ return matched
443
+ return []
444
+
445
+
446
+ def _hook_cards(data: FleetData, records_by_role: dict[str, list[ArtifactRecord]]) -> None:
447
+ fleet = data.fleet
448
+ assert fleet is not None # noqa: S101
449
+ for sview in fleet.sessions:
450
+ acc_session = data.sessions[sview.session_id]
451
+ for aview in sview.agents:
452
+ acc = acc_session.agents[aview.key]
453
+ all_records = data.artifacts.records
454
+ records = _declared_for_role(all_records, acc.role) if acc.role else []
455
+ declared_blockers = sum(1 for r in records if r.kind == "blocker.raised")
456
+ observed_blockers = acc.blockers or (1 if aview.lane == Lane.BLOCKED.value else 0)
457
+ key = f"agent:{sview.session_id}:{aview.key}"
458
+ title = acc.role or ("main" if aview.key == MAIN else aview.key)
459
+ card = Card(
460
+ key=key,
461
+ kind="agent",
462
+ lane=aview.lane,
463
+ title=title,
464
+ detail=f"session {sview.session_id}",
465
+ basis=aview.lane_basis,
466
+ last_ts=aview.last_ts,
467
+ blockers=observed_blockers + declared_blockers,
468
+ declared_blockers=declared_blockers,
469
+ stale=aview.stale,
470
+ session_id=sview.session_id,
471
+ agent_key=aview.key,
472
+ )
473
+ data.cards.append(card)
474
+ data.bundles[key] = AgentBundle(key, acc_session, acc, aview, sview, records)
475
+
476
+
477
+ def _declared_cards(data: FleetData, matched_roles: set[str]) -> None:
478
+ """Roles that only exist as artifact authors (no hook telemetry matches their role)."""
479
+ sessions = reduce_events(data.artifacts.events)
480
+ for session_id, session in sessions.items():
481
+ task = session_id.split(":", 1)[1]
482
+ for agent_key, acc in sorted(session.agents.items()):
483
+ role = acc.role or agent_key
484
+ if role in matched_roles:
485
+ continue
486
+ records = [
487
+ r for r in data.artifacts.records if r.task == task and r.author_role == role
488
+ ]
489
+ key = f"declared:{task}:{role}"
490
+ blockers = sum(1 for r in records if r.kind == "blocker.raised")
491
+ data.cards.append(
492
+ Card(
493
+ key=key,
494
+ kind="declared",
495
+ lane=acc.lane.value,
496
+ title=role,
497
+ detail=f"task {task}",
498
+ basis="self_reported",
499
+ last_ts=acc.last_ts,
500
+ blockers=blockers,
501
+ declared_blockers=blockers,
502
+ agent_key=agent_key,
503
+ )
504
+ )
505
+ data.bundles[key] = AgentBundle(key, session, acc, None, None, records)
506
+
507
+
508
+ def _worktree_cards(data: FleetData) -> None:
509
+ if data.git is None:
510
+ return
511
+ assert data.fleet is not None # noqa: S101
512
+ for wt in data.git.worktrees:
513
+ if _owners(wt, data.fleet):
514
+ continue
515
+ name = Path(wt.path).name or wt.path
516
+ branch = wt.branch or ("detached" if wt.detached else "?")
517
+ if data.hooks_kit == HooksKitState.INSTALLED:
518
+ tail = "no telemetry yet (open Cursor here)"
519
+ else:
520
+ tail = "no hook telemetry"
521
+ data.cards.append(
522
+ Card(
523
+ key=f"wt:{wt.path}",
524
+ kind="worktree",
525
+ lane=Lane.UNKNOWN.value,
526
+ title=name,
527
+ detail=f"branch {branch}, {tail}",
528
+ basis="none",
529
+ last_ts=wt.last_commit_ts,
530
+ worktree_path=wt.path,
531
+ )
532
+ )
533
+
534
+
535
+ def _build_cards(data: FleetData) -> None:
536
+ records_by_role: dict[str, list[ArtifactRecord]] = {}
537
+ for record in data.artifacts.records:
538
+ records_by_role.setdefault(record.author_role, []).append(record)
539
+ data.cards = []
540
+ data.bundles = {}
541
+ if data.fleet is not None:
542
+ _hook_cards(data, records_by_role)
543
+ matched = {b.acc.role for b in data.bundles.values() if b.acc.role}
544
+ _declared_cards(data, matched)
545
+ _worktree_cards(data)
@@ -0,0 +1,82 @@
1
+ """Parsing of the ``/`` filter box.
2
+
3
+ Timeline syntax: whitespace-separated ``key:value`` terms plus free words, for example
4
+ ``agent:reviewer kind:tool risk:high file:src/api since:15m shell``. Keys: ``agent``,
5
+ ``session``, ``kind``, ``risk``, ``source`` (``observed``/``self_reported``/``derived``),
6
+ ``attribution``, ``file``, ``since``, ``until`` (relative ``90s``/``15m``/``2h``/``1d`` or an
7
+ ISO 8601 time; naive times are UTC). Free words must all match. Other views treat the whole
8
+ text as a case-insensitive substring filter.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime, timedelta
16
+
17
+ from cursorfleet.state.event_store import EventFilter
18
+
19
+ _RELATIVE = re.compile(r"^(\d{1,6})([smhd])$")
20
+ _UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
21
+ _KEYS = frozenset(
22
+ {"agent", "session", "kind", "risk", "source", "attribution", "file", "since", "until"}
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ParsedFilter:
28
+ event: EventFilter = field(default_factory=EventFilter)
29
+ errors: tuple[str, ...] = ()
30
+
31
+
32
+ def parse_time(text: str, now: datetime) -> datetime | None:
33
+ match = _RELATIVE.match(text.strip().lower())
34
+ if match:
35
+ return now - timedelta(seconds=int(match.group(1)) * _UNITS[match.group(2)])
36
+ try:
37
+ parsed = datetime.fromisoformat(text.strip().replace("Z", "+00:00"))
38
+ except ValueError:
39
+ return None
40
+ return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
41
+
42
+
43
+ def parse_timeline_filter(text: str, now: datetime) -> ParsedFilter:
44
+ values: dict[str, str] = {}
45
+ words: list[str] = []
46
+ errors: list[str] = []
47
+ for token in text.split():
48
+ key, sep, value = token.partition(":")
49
+ if sep and key.lower() in _KEYS and value:
50
+ values[key.lower()] = value
51
+ else:
52
+ words.append(token)
53
+ since = until = None
54
+ if "since" in values:
55
+ since = parse_time(values.pop("since"), now)
56
+ if since is None:
57
+ errors.append("since: expects 15m, 2h, 1d or an ISO time")
58
+ if "until" in values:
59
+ until = parse_time(values.pop("until"), now)
60
+ if until is None:
61
+ errors.append("until: expects 15m, 2h, 1d or an ISO time")
62
+ return ParsedFilter(
63
+ EventFilter(
64
+ agent=values.get("agent"),
65
+ session=values.get("session"),
66
+ kind=values.get("kind"),
67
+ risk=values.get("risk"),
68
+ source=values.get("source"),
69
+ attribution=values.get("attribution"),
70
+ file=values.get("file"),
71
+ text=" ".join(words) or None,
72
+ since=since,
73
+ until=until,
74
+ ),
75
+ tuple(errors),
76
+ )
77
+
78
+
79
+ def matches_text(haystack: str, text: str) -> bool:
80
+ """Case-insensitive: every whitespace-separated word of ``text`` occurs in ``haystack``."""
81
+ lowered = haystack.lower()
82
+ return all(word in lowered for word in text.lower().split())