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,255 @@
1
+ """Display-only verification observations (v0.1 does NOT enforce gates).
2
+
3
+ Heuristic command classification may be listed here; it never satisfies a gate
4
+ (ADR 0011). PASS/FAIL gate tiles are intentionally not derived from tier 1-3 data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Iterable, Mapping
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+
14
+ from cursorfleet.events.kinds import VERIFICATION_KINDS, EventKind, Outcome
15
+ from cursorfleet.events.models import Event
16
+ from cursorfleet.state.models import utc
17
+
18
+ GATES: tuple[tuple[str, str], ...] = (
19
+ ("unit_tests", "Unit tests"),
20
+ ("integration_tests", "Integration tests"),
21
+ ("type_check", "Type check"),
22
+ ("lint", "Lint"),
23
+ ("security_checks", "Security checks"),
24
+ ("independent_review", "Independent review"),
25
+ ("re_review", "Re-review"),
26
+ ("documentation", "Documentation"),
27
+ ("ci_status", "CI status"),
28
+ ("merge_readiness", "Merge readiness"),
29
+ )
30
+ GATE_LABEL = dict(GATES)
31
+ OBSERVABLE = frozenset({"unit_tests", "integration_tests", "type_check", "lint", "security_checks"})
32
+
33
+ DISPLAY_ONLY = "display-only: v0.1 does not enforce gates"
34
+
35
+ _SPLIT = re.compile(r"\s*(&&|\|\||;|\|)\s*")
36
+ _WORDS = re.compile(r"[a-z0-9_.\-]+")
37
+ _LINT = {"ruff", "eslint", "flake8", "pylint", "golangci-lint", "shellcheck", "hadolint", "clippy"}
38
+ _TYPE = {"mypy", "pyright", "tsc", "typecheck", "type-check"}
39
+ _SECURITY = {"bandit", "semgrep", "trivy", "audit", "pip-audit", "gitleaks", "security"}
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class GateEvidence:
44
+ gate: str
45
+ state: str # "observed" | "unknown" — never pass/fail for heuristics
46
+ ts: datetime
47
+ commit: str | None
48
+ worktree_id: str | None
49
+ session_id: str
50
+ agent: str | None
51
+ origin: str
52
+ command: str | None = None
53
+ note: str | None = None
54
+ exit_outcome: str | None = None # ok | failed | unknown from hook
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class GateStatus:
59
+ gate: str
60
+ label: str
61
+ state: str # not_evaluated | observed | unknown | stale
62
+ detail: str
63
+ evidence: GateEvidence | None = None
64
+ head: str | None = None
65
+
66
+
67
+ _UNIT = frozenset(
68
+ {"pytest", "py.test", "jest", "vitest", "mocha", "rspec", "phpunit", "unittest", "ctest"}
69
+ | {"tox", "nox", "test"}
70
+ )
71
+
72
+
73
+ def _classify_segment(words: list[str]) -> str | None: # noqa: PLR0911
74
+ names = words
75
+ joined = " ".join(names)
76
+ if any(n in _LINT for n in names) and "format" not in names:
77
+ return "lint"
78
+ if any(n in _TYPE for n in names):
79
+ return "type_check"
80
+ if any(n in _SECURITY for n in names):
81
+ return "security_checks"
82
+ if "integration" in joined or "e2e" in joined:
83
+ return "integration_tests"
84
+ if any(n in _UNIT or n.startswith(("test:", "test-")) for n in names):
85
+ return "unit_tests"
86
+ if "lint" in joined:
87
+ return "lint"
88
+ if "check" in joined and "typ" in joined:
89
+ return "type_check"
90
+ return None
91
+
92
+
93
+ def classify_command(
94
+ argv0: str | None, subcommand: str | None, display: str | None
95
+ ) -> tuple[list[str], bool]:
96
+ text = (display or " ".join(p for p in (argv0, subcommand) if p)).lower()
97
+ parts = _SPLIT.split(text)
98
+ segments = parts[0::2]
99
+ separators = parts[1::2]
100
+ gates: list[str] = []
101
+ for segment in segments:
102
+ gate = _classify_segment(_WORDS.findall(segment))
103
+ if gate is not None and gate not in gates:
104
+ gates.append(gate)
105
+ return gates, all(s == "&&" for s in separators)
106
+
107
+
108
+ def _outcome_label(outcome: Outcome | None) -> str:
109
+ if outcome is Outcome.OK:
110
+ return "ok"
111
+ if outcome is Outcome.FAILED:
112
+ return "failed"
113
+ return "unknown"
114
+
115
+
116
+ def evidence_from_events(events: Iterable[Event]) -> list[GateEvidence]:
117
+ """Heuristic verification observations; never mapped to PASS/FAIL gates."""
118
+ found: list[GateEvidence] = []
119
+ for event in events:
120
+ if event.kind is EventKind.GATE_CHANGED and event.gate is not None:
121
+ if event.gate.name in GATE_LABEL:
122
+ found.append(
123
+ _evidence(
124
+ event,
125
+ event.gate.name,
126
+ "unknown",
127
+ "gate.changed",
128
+ note="gate.changed is not tier-4 evidence in v0.1",
129
+ )
130
+ )
131
+ elif event.kind in VERIFICATION_KINDS and event.command is not None:
132
+ cmd = event.command
133
+ gates, all_and = classify_command(cmd.argv0, cmd.subcommand, cmd.display)
134
+ exit_label = _outcome_label(event.outcome)
135
+ note = "heuristic observation; not proof of pass or fail"
136
+ if len(gates) > 1 and not (exit_label == "ok" and all_and):
137
+ note = "chained command: exit code does not identify which step ran; " + note
138
+ elif len(gates) > 1:
139
+ note = "chained && command; " + note
140
+ shown = cmd.display or " ".join(p for p in (cmd.argv0, cmd.subcommand) if p)
141
+ origin = (
142
+ "verification.observed"
143
+ if event.kind is EventKind.VERIFICATION_OBSERVED
144
+ else "test.completed"
145
+ )
146
+ found.extend(
147
+ _evidence(
148
+ event,
149
+ gate,
150
+ "observed",
151
+ origin,
152
+ command=shown,
153
+ note=note,
154
+ exit_outcome=exit_label,
155
+ )
156
+ for gate in gates
157
+ )
158
+ return found
159
+
160
+
161
+ def _evidence( # noqa: PLR0913
162
+ event: Event,
163
+ gate: str,
164
+ state: str,
165
+ origin: str,
166
+ *,
167
+ command: str | None = None,
168
+ note: str | None = None,
169
+ exit_outcome: str | None = None,
170
+ ) -> GateEvidence:
171
+ return GateEvidence(
172
+ gate=gate,
173
+ state=state,
174
+ ts=utc(event.ts),
175
+ commit=event.commit,
176
+ worktree_id=event.worktree_id,
177
+ session_id=event.session_id,
178
+ agent=event.agent_id,
179
+ origin=origin,
180
+ command=command,
181
+ note=note,
182
+ exit_outcome=exit_outcome,
183
+ )
184
+
185
+
186
+ def _same_commit(evidence_commit: str, head: str) -> bool:
187
+ shorter, longer = sorted((evidence_commit.lower(), head.lower()), key=len)
188
+ return longer.startswith(shorter)
189
+
190
+
191
+ def evaluate(
192
+ evidence: Iterable[GateEvidence], *, worktree_id: str | None, head: str | None
193
+ ) -> list[GateStatus]:
194
+ latest: dict[str, GateEvidence] = {}
195
+ for item in evidence:
196
+ if item.worktree_id != worktree_id:
197
+ continue
198
+ current = latest.get(item.gate)
199
+ if current is None or (item.ts, item.session_id) > (current.ts, current.session_id):
200
+ latest[item.gate] = item
201
+ rows: list[GateStatus] = []
202
+ for gate, label in GATES:
203
+ found = latest.get(gate)
204
+ if found is None:
205
+ why = "not evaluated" if gate in OBSERVABLE else "not evaluated (not observable)"
206
+ rows.append(GateStatus(gate, label, "not_evaluated", why, None, head))
207
+ continue
208
+ rows.append(_judge(gate, label, found, head))
209
+ return rows
210
+
211
+
212
+ def _judge(gate: str, label: str, item: GateEvidence, head: str | None) -> GateStatus:
213
+ sha = item.commit[:8] if item.commit else None
214
+ exit_bit = f", exit {item.exit_outcome}" if item.exit_outcome else ""
215
+ if item.commit is None:
216
+ return GateStatus(
217
+ gate,
218
+ label,
219
+ "observed",
220
+ f"heuristic observation{exit_bit}; not bound to a commit",
221
+ item,
222
+ head,
223
+ )
224
+ if head is None:
225
+ return GateStatus(
226
+ gate,
227
+ label,
228
+ "observed",
229
+ f"heuristic observation{exit_bit} @{sha}; HEAD unknown",
230
+ item,
231
+ head,
232
+ )
233
+ if not _same_commit(item.commit, head):
234
+ return GateStatus(
235
+ gate,
236
+ label,
237
+ "stale",
238
+ f"STALE: observation{exit_bit} was @{sha}, HEAD now @{head[:8]}",
239
+ item,
240
+ head,
241
+ )
242
+ return GateStatus(
243
+ gate,
244
+ label,
245
+ "observed",
246
+ f"heuristic observation{exit_bit} @{sha} (HEAD)",
247
+ item,
248
+ head,
249
+ )
250
+
251
+
252
+ def evaluate_all(
253
+ evidence: list[GateEvidence], heads: Mapping[str | None, str | None]
254
+ ) -> dict[str | None, list[GateStatus]]:
255
+ return {wt: evaluate(evidence, worktree_id=wt, head=head) for wt, head in heads.items()}
@@ -0,0 +1,101 @@
1
+ """Help screen text (kept as data so tests and docs can assert on it)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import VerticalScroll
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import Static
11
+
12
+ from cursorfleet.tui.gates import DISPLAY_ONLY
13
+ from cursorfleet.tui.layout import SINGLE_BELOW, TILE_FROM
14
+
15
+ KEYS: tuple[tuple[str, str], ...] = (
16
+ ("a", "Active run: task-centric roles and instances (default)"),
17
+ ("u", "Active runs: persisted runs for this repo"),
18
+ ("o", "Sessions (archive): full session-centric overview"),
19
+ ("l", "Timeline: filterable event stream (newest first)"),
20
+ ("w", "Worktrees: branch, HEAD, dirty, ahead/behind, owner"),
21
+ ("g", "Observations: verification signals, display-only (no pass/fail score)"),
22
+ ("e", "Evidence: observed runs vs SELF-REPORTED declarations"),
23
+ ("j / k, arrows", "Move down / up (scrolls the detail when it has focus)"),
24
+ ("Enter", "Open the detail for the selected item"),
25
+ ("Esc", "Back: leave detail, close or clear the filter"),
26
+ ("/", "Filter (timeline: agent: session: kind: risk: source: file: since: until: words)"),
27
+ ("m", "Timeline: load more events"),
28
+ ("p", "Pin the selected agent to the top of its lane (this session only)"),
29
+ ("t", f"Toggle 2x2 tiled panes ({TILE_FROM}+ columns only)"),
30
+ ("r", "Re-read now (index, git, artifacts); never fetches"),
31
+ ("Tab", "Move focus between filter, list and detail"),
32
+ ("?", "This help"),
33
+ ("q", "Quit"),
34
+ )
35
+
36
+ HONESTY: tuple[str, ...] = (
37
+ "CursorFleet v0.1 OBSERVES. It does not enforce, approve or block anything.",
38
+ "Primary labels: Observed, Declared, Inferred, Unavailable, Conflict (not obs/drv/SELF).",
39
+ "Detail panes may still show technical source/attribution for power users.",
40
+ "A silent agent is shown STALE / OFFLINE, never idle. No telemetry means UNKNOWN.",
41
+ "session_id groups one Cursor chat; it is correlation only, not agent identity.",
42
+ "Q1 and Q2 (subagent identity in hooks) are OPEN; incomplete lifecycles stay visible.",
43
+ "Token and cost budgets are unknown: Cursor hooks do not expose them.",
44
+ f"Observations: {DISPLAY_ONLY}. Each signal stands alone; there is no overall score.",
45
+ "Cloud agents are not visible. Local Cursor sessions only.",
46
+ "Status is always written as text; colour is only a hint and NO_COLOR is honoured.",
47
+ )
48
+
49
+ LAYOUTS: tuple[str, ...] = (
50
+ f"Under {SINGLE_BELOW} columns: one pane (Enter opens the detail, Esc returns).",
51
+ f"{SINGLE_BELOW}-{TILE_FROM - 1} columns: list on the left, detail on the right.",
52
+ f"{TILE_FROM}+ columns: same, or press t for a 2x2 grid of list, detail, recent events "
53
+ "and gates/worktrees.",
54
+ )
55
+
56
+ SETUP: tuple[str, ...] = (
57
+ "No data? Run `cursorfleet init --cursor` to install the hooks, then use Cursor.",
58
+ "`cursorfleet doctor` explains what is missing.",
59
+ )
60
+
61
+
62
+ def help_text() -> Text:
63
+ text = Text("CursorFleet help\n\n", style="bold")
64
+ text.append("KEYS\n", style="bold")
65
+ for key, meaning in KEYS:
66
+ text.append(f" {key:<14}", style="bold")
67
+ text.append(f"{meaning}\n")
68
+ text.append("\nLAYOUT\n", style="bold")
69
+ for line in LAYOUTS:
70
+ text.append(f" {line}\n")
71
+ text.append("\nHONESTY\n", style="bold")
72
+ for line in HONESTY:
73
+ text.append(f" {line}\n")
74
+ text.append("\nSETUP\n", style="bold")
75
+ for line in SETUP:
76
+ text.append(f" {line}\n")
77
+ text.append("\nPress Esc, ? or q to close.\n", style="dim")
78
+ return text
79
+
80
+
81
+ class HelpScreen(ModalScreen[None]):
82
+ """Modal help. Closing it never quits the app."""
83
+
84
+ BINDINGS = [ # noqa: RUF012 - Textual convention
85
+ Binding("escape", "close", "Close", show=False),
86
+ Binding("question_mark", "close", "Close", show=False),
87
+ Binding("q", "close", "Close", show=False),
88
+ ]
89
+ DEFAULT_CSS = """
90
+ HelpScreen { align: center middle; }
91
+ HelpScreen > VerticalScroll {
92
+ width: 90%; max-width: 100; height: 90%; border: round $primary; padding: 0 1;
93
+ }
94
+ """
95
+
96
+ def compose(self) -> ComposeResult:
97
+ with VerticalScroll():
98
+ yield Static(help_text(), id="help_text")
99
+
100
+ def action_close(self) -> None:
101
+ self.dismiss(None)
@@ -0,0 +1,26 @@
1
+ """Responsive layout rules (docs/tui.md).
2
+
3
+ - below 100 columns: ONE pane (the list, or the detail after Enter);
4
+ - 100-159 columns: list + detail side by side;
5
+ - 160+ columns: list + detail, or an optional 2x2 tile grid (``t``).
6
+
7
+ Four panes are never placed side by side: tiles form a 2x2 grid, so each pane keeps at
8
+ least ~80 columns.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ SINGLE_BELOW = 100
14
+ TILE_FROM = 160
15
+
16
+ SINGLE = "single"
17
+ SPLIT = "split"
18
+ TILED = "tiled"
19
+
20
+
21
+ def layout_for(width: int, tile_enabled: bool) -> str:
22
+ if width < SINGLE_BELOW:
23
+ return SINGLE
24
+ if width >= TILE_FROM and tile_enabled:
25
+ return TILED
26
+ return SPLIT
@@ -0,0 +1,176 @@
1
+ """Text helpers and styling for the TUI.
2
+
3
+ Rules enforced here for every screen:
4
+
5
+ - Untrusted strings (roles, branches, paths, refs) go through :func:`safe` and are placed in
6
+ ``rich.text.Text`` objects, never in markup strings, so ``[bold]``-style content from a
7
+ forged artifact cannot restyle or hide the UI.
8
+ - Status is never conveyed by colour alone: every state has a text label and an ASCII
9
+ marker (:data:`LANE_TAG`, :data:`LANE_MARK`). Colour is only an enhancement and is
10
+ replaced by bold/dim/reverse when ``NO_COLOR`` is set (:class:`Theme`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+
18
+ from cursorfleet.state.models import Lane, utc
19
+
20
+ LANE_ORDER: tuple[str, ...] = (
21
+ Lane.QUEUED.value,
22
+ Lane.LOADING_CONTEXT.value,
23
+ Lane.PLANNING.value,
24
+ Lane.WORKING.value,
25
+ Lane.VERIFYING.value,
26
+ Lane.AWAITING_REVIEW.value,
27
+ Lane.PATCHING.value,
28
+ Lane.BLOCKED.value,
29
+ Lane.DONE.value,
30
+ Lane.STALE_OFFLINE.value,
31
+ Lane.UNKNOWN.value,
32
+ )
33
+
34
+ LANE_TAG: dict[str, str] = {
35
+ "queued": "QUEUED",
36
+ "loading_context": "LOADING CONTEXT",
37
+ "planning": "PLANNING",
38
+ "working": "WORKING",
39
+ "verifying": "VERIFYING",
40
+ "awaiting_review": "AWAITING REVIEW",
41
+ "patching": "PATCHING",
42
+ "blocked": "BLOCKED",
43
+ "done": "DONE",
44
+ "stale_offline": "STALE / OFFLINE",
45
+ "unknown": "UNKNOWN / NO TELEMETRY",
46
+ }
47
+
48
+ LANE_MARK: dict[str, str] = {
49
+ "queued": "[Q]",
50
+ "loading_context": "[C]",
51
+ "planning": "[P]",
52
+ "working": "[W]",
53
+ "verifying": "[V]",
54
+ "awaiting_review": "[R]",
55
+ "patching": "[X]",
56
+ "blocked": "[!]",
57
+ "done": "[D]",
58
+ "stale_offline": "[~]",
59
+ "unknown": "[?]",
60
+ }
61
+
62
+ BASIS_TAG: dict[str, str] = {
63
+ "observed_activity": "observed",
64
+ "lifecycle_only": "lifecycle-only",
65
+ "self_reported": "SELF-REPORTED",
66
+ "derived": "derived",
67
+ "none": "no telemetry",
68
+ }
69
+
70
+ _COLOR: dict[str, str] = {
71
+ "blocked": "bold red",
72
+ "working": "cyan",
73
+ "verifying": "magenta",
74
+ "awaiting_review": "yellow",
75
+ "patching": "blue",
76
+ "done": "green",
77
+ "stale_offline": "dim yellow",
78
+ "unknown": "dim",
79
+ }
80
+ _MONO: dict[str, str] = {
81
+ "blocked": "bold reverse",
82
+ "working": "bold",
83
+ "verifying": "bold",
84
+ "awaiting_review": "underline",
85
+ "patching": "underline",
86
+ "done": "dim",
87
+ "stale_offline": "dim italic",
88
+ "unknown": "dim",
89
+ }
90
+
91
+ GATE_COLOR = {
92
+ "pass": "green",
93
+ "fail": "bold red",
94
+ "stale": "yellow",
95
+ "unknown": "dim",
96
+ "not_evaluated": "dim",
97
+ "observed": "cyan",
98
+ }
99
+ GATE_MONO = {
100
+ "pass": "bold",
101
+ "fail": "bold reverse",
102
+ "stale": "underline",
103
+ "unknown": "dim",
104
+ "not_evaluated": "dim",
105
+ "observed": "bold",
106
+ }
107
+
108
+ UNKNOWN_BUDGET = "unknown (not exposed by Cursor hooks)"
109
+ SELF_REPORTED_NOTE = "SELF-REPORTED (an agent's claim, not evidence)"
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class Theme:
114
+ """Style lookup; ``mono`` drops every colour (``NO_COLOR`` or ``--no-color``)."""
115
+
116
+ mono: bool = False
117
+
118
+ def lane(self, lane: str) -> str:
119
+ table = _MONO if self.mono else _COLOR
120
+ return table.get(lane, "")
121
+
122
+ def gate(self, state: str) -> str:
123
+ return (GATE_MONO if self.mono else GATE_COLOR).get(state, "")
124
+
125
+ @property
126
+ def accent(self) -> str:
127
+ return "bold" if self.mono else "bold cyan"
128
+
129
+ @property
130
+ def dim(self) -> str:
131
+ return "dim"
132
+
133
+
134
+ def safe(value: object, limit: int = 160) -> str:
135
+ """Printable, single-line, bounded text for untrusted values."""
136
+ text = "".join(ch if ch.isprintable() else "?" for ch in str(value))
137
+ return text if len(text) <= limit else text[: max(limit - 3, 0)] + "..."
138
+
139
+
140
+ def short_sha(value: str | None, width: int = 8) -> str:
141
+ return value[:width] if value else "-"
142
+
143
+
144
+ def fmt_ts(value: datetime | None) -> str:
145
+ """Compact absolute UTC time; the UI states that all times are UTC."""
146
+ return "-" if value is None else utc(value).strftime("%m-%d %H:%M:%S")
147
+
148
+
149
+ def fmt_age(now: datetime, then: datetime | None) -> str:
150
+ if then is None:
151
+ return "never"
152
+ seconds = int((utc(now) - utc(then)).total_seconds())
153
+ if seconds < 0:
154
+ return "clock skew"
155
+ if seconds < 60:
156
+ return f"{seconds}s ago"
157
+ if seconds < 3600:
158
+ return f"{seconds // 60}m ago"
159
+ if seconds < 86400:
160
+ return f"{seconds // 3600}h ago"
161
+ return f"{seconds // 86400}d ago"
162
+
163
+
164
+ def fmt_duration(seconds: float) -> str:
165
+ total = int(seconds)
166
+ hours, rest = divmod(total, 3600)
167
+ minutes, secs = divmod(rest, 60)
168
+ if hours:
169
+ return f"{hours}h{minutes:02d}m{secs:02d}s"
170
+ if minutes:
171
+ return f"{minutes}m{secs:02d}s"
172
+ return f"{secs}s"
173
+
174
+
175
+ def plural(count: int, noun: str) -> str:
176
+ return f"{count} {noun}" + ("" if count == 1 else "s")