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,132 @@
1
+ """Event vocabulary: enums and constants. Stdlib only (shared with the hook hot path).
2
+
3
+ There is deliberately no kind for thoughts, prompts or responses
4
+ (ADR 0004). Adding one needs a superseding ADR.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import StrEnum
10
+
11
+ SCHEMA_VERSION = "1.0"
12
+
13
+ # Stored in place of any path outside the workspace roots.
14
+ EXTERNAL_PATH = "<external>"
15
+
16
+ # Hard bounds shared by the models and the stdlib sanitizer.
17
+ COMMAND_DISPLAY_MAX_CHARS = 200
18
+ MAX_PATHS_PER_EVENT = 200
19
+
20
+
21
+ class EventKind(StrEnum):
22
+ SESSION_STARTED = "session.started"
23
+ SESSION_STOPPED = "session.stopped"
24
+ STATUS_CHANGED = "status.changed"
25
+ TOOL_STARTED = "tool.started"
26
+ TOOL_COMPLETED = "tool.completed"
27
+ TOOL_FAILED = "tool.failed"
28
+ FILE_CHANGED = "file.changed"
29
+ TEST_COMPLETED = "test.completed" # legacy spool lines; prefer VERIFICATION_OBSERVED
30
+ VERIFICATION_OBSERVED = "verification.observed"
31
+ SUBAGENT_STARTED = "subagent.started"
32
+ SUBAGENT_STOPPED = "subagent.stopped"
33
+ CONTEXT_COMPACTED = "context.compacted"
34
+ PLAN_CREATED = "plan.created"
35
+ HANDOFF_CREATED = "handoff.created"
36
+ BLOCKER_RAISED = "blocker.raised"
37
+ GATE_CHANGED = "gate.changed"
38
+ CONTEXT_LOADED = "context.loaded"
39
+
40
+
41
+ class Source(StrEnum):
42
+ """How we know an event happened. Never display self_reported as fact."""
43
+
44
+ OBSERVED = "observed" # directly from a Cursor hook payload
45
+ SELF_REPORTED = "self_reported" # declared by an agent (artifact or emit CLI)
46
+ DERIVED = "derived" # computed by CursorFleet from observed events or git
47
+
48
+
49
+ class Producer(StrEnum):
50
+ CURSOR_HOOK = "cursor_hook"
51
+ WORK_ARTIFACT = "work_artifact"
52
+ EMIT_CLI = "emit_cli"
53
+ INDEXER = "indexer"
54
+
55
+
56
+ class Risk(StrEnum):
57
+ """Informational label only; v0.1 never acts on it."""
58
+
59
+ NONE = "none"
60
+ LOW = "low"
61
+ MEDIUM = "medium"
62
+ HIGH = "high"
63
+
64
+
65
+ class Attribution(StrEnum):
66
+ """How confidently the event is tied to ``agent_role``/``agent_instance_id``.
67
+
68
+ PROVISIONAL (ADR 0001 Q1): tool hooks may not identify the subagent, so most
69
+ tool events may legitimately be ``unknown`` or ``inferred``.
70
+ """
71
+
72
+ EXACT = "exact" # identity present in the payload itself
73
+ INFERRED = "inferred" # temporal window, worktree or tool_use_id linkage
74
+ UNKNOWN = "unknown"
75
+
76
+
77
+ class Outcome(StrEnum):
78
+ OK = "ok"
79
+ FAILED = "failed"
80
+ TIMEOUT = "timeout"
81
+ PERMISSION_DENIED = "permission_denied"
82
+ INTERRUPTED = "interrupted"
83
+ UNKNOWN = "unknown"
84
+
85
+
86
+ class AgentStatus(StrEnum):
87
+ IDLE = "idle"
88
+ WORKING = "working"
89
+ WAITING = "waiting"
90
+ BLOCKED = "blocked"
91
+ DONE = "done"
92
+ ERROR = "error"
93
+ UNKNOWN = "unknown"
94
+
95
+
96
+ class GateState(StrEnum):
97
+ PASSING = "passing"
98
+ FAILING = "failing"
99
+ STALE = "stale"
100
+ UNKNOWN = "unknown"
101
+
102
+
103
+ class PathOp(StrEnum):
104
+ CREATED = "created"
105
+ MODIFIED = "modified"
106
+ DELETED = "deleted"
107
+ UNKNOWN = "unknown"
108
+
109
+
110
+ # Kinds Cursor cannot observe: they may only come from an agent declaration.
111
+ SELF_REPORT_ONLY_KINDS: frozenset[EventKind] = frozenset(
112
+ {
113
+ EventKind.PLAN_CREATED,
114
+ EventKind.HANDOFF_CREATED,
115
+ EventKind.BLOCKER_RAISED,
116
+ EventKind.CONTEXT_LOADED,
117
+ }
118
+ )
119
+
120
+ # Kinds an agent may declare. Tool, file and subagent facts cannot be self-reported.
121
+ SELF_REPORTABLE_KINDS: frozenset[EventKind] = SELF_REPORT_ONLY_KINDS | {EventKind.STATUS_CHANGED}
122
+
123
+ VERIFICATION_KINDS: frozenset[EventKind] = frozenset(
124
+ {EventKind.TEST_COMPLETED, EventKind.VERIFICATION_OBSERVED}
125
+ )
126
+
127
+
128
+ def normalize_event_kind(kind: EventKind) -> EventKind:
129
+ """Map legacy verification kinds to the canonical enum member."""
130
+ if kind is EventKind.TEST_COMPLETED:
131
+ return EventKind.VERIFICATION_OBSERVED
132
+ return kind
@@ -0,0 +1,213 @@
1
+ """Versioned event model (schema_version "1.0"): the source of truth for event.schema.json.
2
+
3
+ The stdlib-only hook hot path does NOT import this module. It re-implements
4
+ validation with the stdlib and is contract-tested against these models
5
+ (docs/architecture.md). Every model forbids extra keys, so an unlisted field
6
+ (a prompt, an email, a transcript path) can never ride along in a valid event.
7
+
8
+ PROVISIONAL (ADR 0001 Q1, Q2): ``agent_role``, ``agent_instance_id``,
9
+ ``agent_id`` and ``attribution`` depend on what subagent tool hooks reveal.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Annotated, Any, Literal, Self
15
+
16
+ from pydantic import AfterValidator, AwareDatetime, BaseModel, ConfigDict, Field, model_validator
17
+
18
+ from cursorfleet.events.ids import AGENT_ID_PATTERN, SAFE_ID_PATTERN, ULID_PATTERN, derive_agent_id
19
+ from cursorfleet.events.kinds import (
20
+ COMMAND_DISPLAY_MAX_CHARS,
21
+ MAX_PATHS_PER_EVENT,
22
+ SCHEMA_VERSION,
23
+ SELF_REPORT_ONLY_KINDS,
24
+ SELF_REPORTABLE_KINDS,
25
+ AgentStatus,
26
+ Attribution,
27
+ EventKind,
28
+ GateState,
29
+ Outcome,
30
+ PathOp,
31
+ Producer,
32
+ Risk,
33
+ Source,
34
+ )
35
+ from cursorfleet.events.pathcheck import check_relative_posix
36
+
37
+
38
+ def _printable(value: str) -> str:
39
+ if not value.isprintable():
40
+ msg = "must not contain control, bidi-override or other non-printable characters"
41
+ raise ValueError(msg)
42
+ return value
43
+
44
+
45
+ def _relative_or_external(value: str) -> str:
46
+ return check_relative_posix(value, allow_external=True)
47
+
48
+
49
+ SafeId = Annotated[str, Field(pattern=SAFE_ID_PATTERN, max_length=128)]
50
+ AgentId = Annotated[str, Field(pattern=AGENT_ID_PATTERN, max_length=257)]
51
+ Ulid = Annotated[str, Field(pattern=ULID_PATTERN, min_length=26, max_length=26)]
52
+ VersionStr = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._+\-]{0,63}$", max_length=64)]
53
+ Printable = Annotated[str, AfterValidator(_printable)]
54
+ NonNegInt = Annotated[int, Field(ge=0, le=10**12, strict=True)]
55
+ EventPath = Annotated[str, AfterValidator(_relative_or_external)]
56
+
57
+ _STRICT = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=False)
58
+
59
+
60
+ class SanitizedPath(BaseModel):
61
+ """A path reduced to a workspace-relative POSIX string, or ``<external>``."""
62
+
63
+ model_config = _STRICT
64
+
65
+ path: EventPath = Field(
66
+ description="Workspace-relative, symlink-resolved, '/' separated; "
67
+ "'<external>' for anything outside the workspace roots."
68
+ )
69
+ op: PathOp = PathOp.UNKNOWN
70
+
71
+
72
+ class SanitizedCommand(BaseModel):
73
+ """A shell command reduced to non-sensitive facts. Never the raw command line."""
74
+
75
+ model_config = _STRICT
76
+
77
+ argv0: Annotated[str, Field(pattern=r"^[A-Za-z0-9_][A-Za-z0-9._+\-]{0,63}$")] = Field(
78
+ description="Executable basename only (no directory)."
79
+ )
80
+ subcommand: Annotated[str, Field(pattern=r"^[a-z][a-z0-9:_\-]{0,63}$")] | None = Field(
81
+ default=None, description="First non-flag word for known multi-command tools."
82
+ )
83
+ exit_code: Annotated[int, Field(ge=-1024, le=1024, strict=True)] | None = None
84
+ duration_ms: NonNegInt | None = None
85
+ display: Annotated[Printable, Field(max_length=COMMAND_DISPLAY_MAX_CHARS)] | None = Field(
86
+ default=None,
87
+ description="Redacted and truncated single-line display string (best effort, "
88
+ "not a security guarantee).",
89
+ )
90
+ display_truncated: bool = False
91
+ command_hash: Annotated[str, Field(pattern=r"^[0-9a-f]{16,64}$")] | None = Field(
92
+ default=None,
93
+ description="Keyed (HMAC) hash of the normalized argv for dedupe; key is local.",
94
+ )
95
+
96
+
97
+ class GateRef(BaseModel):
98
+ """Display-only gate signal (v0.1 never enforces gates)."""
99
+
100
+ model_config = _STRICT
101
+
102
+ name: Annotated[str, Field(pattern=r"^[a-z][a-z0-9_\-]{0,31}$")]
103
+ state: GateState
104
+
105
+
106
+ class Metrics(BaseModel):
107
+ """Bounded counters Cursor exposes. Token usage appears only via compaction."""
108
+
109
+ model_config = _STRICT
110
+
111
+ duration_ms: NonNegInt | None = None
112
+ tool_call_count: NonNegInt | None = None
113
+ message_count: NonNegInt | None = None
114
+ loop_count: NonNegInt | None = None
115
+ context_usage_percent: Annotated[float, Field(ge=0, le=1000)] | None = None
116
+ context_tokens: NonNegInt | None = None
117
+ context_window_size: NonNegInt | None = None
118
+
119
+
120
+ class Event(BaseModel):
121
+ """One normalized CursorFleet event (schema_version "1.0")."""
122
+
123
+ model_config = _STRICT
124
+
125
+ schema_version: Literal["1.0"] = SCHEMA_VERSION # type: ignore[assignment]
126
+ event_id: Ulid = Field(description="ULID-style id (26 Crockford base32 chars).")
127
+ ts: AwareDatetime = Field(description="Timezone-aware event time.")
128
+ producer: Producer
129
+ producer_version: VersionStr
130
+ cursor_version: VersionStr | None = Field(
131
+ default=None, description="Cursor version from the payload; absent for non-hook producers."
132
+ )
133
+ source: Source = Field(description="observed | self_reported | derived. Show it in the UI.")
134
+ kind: EventKind
135
+
136
+ session_id: SafeId = Field(description="Cursor conversation_id.")
137
+ generation_id: SafeId | None = None
138
+ agent_role: SafeId | None = Field(
139
+ default=None,
140
+ description="Roster id when subagent_type maps to it, else the raw subagent_type. "
141
+ "PROVISIONAL (ADR 0001 Q2).",
142
+ )
143
+ agent_instance_id: SafeId | None = Field(
144
+ default=None, description="Cursor subagent_id. PROVISIONAL (ADR 0001 Q1)."
145
+ )
146
+ agent_id: AgentId | None = Field(
147
+ default=None,
148
+ description="Derived as 'role#instance'; if given it must equal the derived value.",
149
+ )
150
+ attribution: Attribution = Attribution.UNKNOWN
151
+
152
+ risk: Risk = Risk.NONE
153
+ worktree_id: SafeId | None = None
154
+ branch: Annotated[Printable, Field(min_length=1, max_length=255)] | None = None
155
+ commit: Annotated[str, Field(pattern=r"^[0-9a-f]{7,64}$")] | None = None
156
+ issue_ref: Annotated[str, Field(pattern=r"^[A-Za-z0-9_.\-/#]{1,100}$")] | None = None
157
+
158
+ tool_name: Annotated[str, Field(pattern=r"^[A-Za-z0-9_:.\-]{1,128}$")] | None = None
159
+ tool_use_id: SafeId | None = Field(
160
+ default=None,
161
+ description="Opaque Cursor tool_use_id (tool hooks) or tool_call_id (subagentStart); "
162
+ "pairs started/completed and is the candidate Task-to-subagent link. "
163
+ "PROVISIONAL (ADR 0001 section B).",
164
+ )
165
+ hook: Annotated[str, Field(pattern=r"^[A-Za-z][A-Za-z0-9]{0,63}$")] | None = Field(
166
+ default=None,
167
+ description="Cursor hook_event_name that produced the event; lets the reducer "
168
+ "de-duplicate overlapping hooks (preToolUse vs beforeShellExecution).",
169
+ )
170
+ outcome: Outcome | None = None
171
+ status: AgentStatus | None = None
172
+ gate: GateRef | None = None
173
+ command: SanitizedCommand | None = None
174
+ paths: Annotated[tuple[SanitizedPath, ...], Field(max_length=MAX_PATHS_PER_EVENT)] = ()
175
+ metrics: Metrics | None = None
176
+
177
+ @model_validator(mode="before")
178
+ @classmethod
179
+ def _fill_agent_id(cls, data: Any) -> Any:
180
+ if isinstance(data, dict) and data.get("agent_id") is None:
181
+ derived = derive_agent_id(data.get("agent_role"), data.get("agent_instance_id"))
182
+ if derived is not None:
183
+ return {**data, "agent_id": derived}
184
+ return data
185
+
186
+ @model_validator(mode="after")
187
+ def _check_consistency(self) -> Self:
188
+ derived = derive_agent_id(self.agent_role, self.agent_instance_id)
189
+ if self.agent_id != derived:
190
+ msg = "agent_id must equal the value derived from agent_role and agent_instance_id"
191
+ raise ValueError(msg)
192
+ if self.kind in SELF_REPORT_ONLY_KINDS and self.source is not Source.SELF_REPORTED:
193
+ msg = f"{self.kind.value} is not observable by Cursor hooks; use source=self_reported"
194
+ raise ValueError(msg)
195
+ if self.source is Source.SELF_REPORTED and self.kind not in SELF_REPORTABLE_KINDS:
196
+ msg = f"{self.kind.value} cannot be self-reported"
197
+ raise ValueError(msg)
198
+ if (self.kind is EventKind.GATE_CHANGED) != (self.gate is not None):
199
+ msg = "gate is required for, and only allowed on, gate.changed"
200
+ raise ValueError(msg)
201
+ if self.kind is EventKind.FILE_CHANGED and not self.paths:
202
+ msg = "file.changed requires at least one path"
203
+ raise ValueError(msg)
204
+ return self
205
+
206
+
207
+ __all__ = [
208
+ "Event",
209
+ "GateRef",
210
+ "Metrics",
211
+ "SanitizedCommand",
212
+ "SanitizedPath",
213
+ ]
@@ -0,0 +1,45 @@
1
+ """Stdlib-only validation for workspace-relative POSIX paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from cursorfleet.events.kinds import EXTERNAL_PATH
8
+
9
+ _DRIVE = re.compile(r"^[A-Za-z]:")
10
+ MAX_PATH_CHARS = 1024
11
+
12
+
13
+ def check_relative_posix(value: str, *, allow_external: bool = False) -> str:
14
+ """Return ``value`` if it is a safe workspace-relative POSIX path, else raise ValueError.
15
+
16
+ Rejects absolute paths, drive letters, backslashes, empty/``.``/``..`` segments
17
+ and non-printable characters (control, bidi override, line separators).
18
+ """
19
+ if allow_external and value == EXTERNAL_PATH:
20
+ return value
21
+ if not value or len(value) > MAX_PATH_CHARS:
22
+ msg = "path must be 1..1024 characters"
23
+ raise ValueError(msg)
24
+ if not value.isprintable():
25
+ msg = "path contains non-printable characters"
26
+ raise ValueError(msg)
27
+ if "\\" in value or value.startswith("/") or _DRIVE.match(value):
28
+ msg = "path must be workspace-relative with '/' separators"
29
+ raise ValueError(msg)
30
+ if any(part in {"", ".", ".."} for part in value.split("/")):
31
+ msg = "path must not contain empty, '.' or '..' segments"
32
+ raise ValueError(msg)
33
+ return value
34
+
35
+
36
+ def check_not_in_git_dir(value: str) -> str:
37
+ """Raise ``ValueError`` if ``value`` has a ``.git`` segment (any case); else return it.
38
+
39
+ For locations CursorFleet *writes or deletes* (work dir, lockfile paths): a hostile
40
+ config or lockfile must not be able to aim those operations at repository internals.
41
+ """
42
+ if any(part.lower() == ".git" for part in value.split("/")):
43
+ msg = "path must not be inside .git"
44
+ raise ValueError(msg)
45
+ return value
File without changes
@@ -0,0 +1,226 @@
1
+ """Read-only git/worktree collector. No fetch, no mutation, argv lists, timeouts everywhere.
2
+
3
+ Produces a :class:`GitSnapshot` that is useful with ZERO hook telemetry ("git-only"):
4
+ branch, HEAD, dirty file count, ahead/behind versus the EXISTING upstream ref (we never
5
+ fetch, so it can lag the remote), stale detection and the last commit time. Linking a
6
+ worktree to an owning session/task is done by the caller from state (``activity`` and
7
+ ``worktree_id``), never by this module guessing.
8
+
9
+ Cursor creates, discovers and deletes worktrees itself (ADR 0001 A8), so a worktree may
10
+ vanish between two calls; every field degrades to ``None`` instead of raising.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from collections.abc import Mapping
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from dataclasses import dataclass, field
19
+ from datetime import UTC, datetime, timedelta
20
+
21
+ from cursorfleet.events.ids import worktree_id_for
22
+ from cursorfleet.git.runner import (
23
+ DEFAULT_TIMEOUT_S,
24
+ GitResult,
25
+ GitUnavailable,
26
+ NotAGitRepo,
27
+ git_common_dir,
28
+ run_git,
29
+ )
30
+ from cursorfleet.git.worktrees import WorktreeEntry, parse_porcelain
31
+
32
+ DEFAULT_STALE_AFTER_HOURS = 72
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class WorktreeSnapshot:
37
+ path: str # as reported by git
38
+ real_path: str | None # realpath if it exists
39
+ worktree_id: str | None
40
+ is_main: bool
41
+ exists: bool
42
+ branch: str | None
43
+ head: str | None
44
+ detached: bool
45
+ bare: bool
46
+ locked: bool
47
+ prunable: bool
48
+ dirty_count: int | None
49
+ upstream: bool # an upstream ref is configured and resolvable
50
+ ahead: int | None
51
+ behind: int | None
52
+ last_commit_ts: datetime | None
53
+ stale: bool
54
+ stale_reasons: tuple[str, ...]
55
+ error: str | None = None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class GitSnapshot:
60
+ available: bool
61
+ error: str | None
62
+ common_dir: str | None
63
+ collected_at: datetime
64
+ worktrees: tuple[WorktreeSnapshot, ...] = field(default_factory=tuple)
65
+
66
+
67
+ def _empty(now: datetime, error: str, common_dir: str | None = None) -> GitSnapshot:
68
+ return GitSnapshot(False, error, common_dir, now)
69
+
70
+
71
+ def count_status_entries(raw: bytes) -> int:
72
+ """Count entries in ``git status --porcelain=v1 -z`` output (rename/copy have 2 fields)."""
73
+ fields = raw.split(b"\0")
74
+ count = 0
75
+ index = 0
76
+ while index < len(fields):
77
+ entry = fields[index]
78
+ index += 1
79
+ if not entry:
80
+ continue
81
+ count += 1
82
+ if len(entry) >= 2 and (entry[:1] in b"RC" or entry[1:2] in b"RC"):
83
+ index += 1 # the original path follows as a separate field
84
+ return count
85
+
86
+
87
+ def _safe_run(args: list[str], cwd: str, timeout: float) -> GitResult | None:
88
+ try:
89
+ return run_git(args, cwd, timeout=timeout)
90
+ except GitUnavailable:
91
+ return None
92
+
93
+
94
+ def _parse_ahead_behind(text: str) -> tuple[int, int] | None:
95
+ parts = text.split()
96
+ if len(parts) != 2 or not all(p.isdigit() for p in parts):
97
+ return None
98
+ behind, ahead = int(parts[0]), int(parts[1]) # left = upstream-only, right = HEAD-only
99
+ return ahead, behind
100
+
101
+
102
+ def _probe( # noqa: PLR0913
103
+ entry: WorktreeEntry,
104
+ *,
105
+ is_main: bool,
106
+ now: datetime,
107
+ stale_after: timedelta,
108
+ activity: datetime | None,
109
+ timeout: float,
110
+ ahead_behind: bool,
111
+ ) -> WorktreeSnapshot:
112
+ reasons: list[str] = []
113
+ error: str | None = None
114
+ exists = os.path.isabs(entry.path) and os.path.isdir(entry.path)
115
+ real = os.path.realpath(entry.path) if exists else None
116
+ dirty = ahead = behind = None
117
+ upstream = False
118
+ last_commit: datetime | None = None
119
+ if not exists:
120
+ reasons.append("missing_path")
121
+ if entry.prunable:
122
+ reasons.append("prunable")
123
+ probe = exists and not entry.bare and not entry.prunable and real is not None
124
+ if probe and real is not None:
125
+ status = _safe_run(
126
+ ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], real, timeout
127
+ )
128
+ if status is not None and status.ok:
129
+ dirty = count_status_entries(status.stdout)
130
+ else:
131
+ error = "status_failed"
132
+ if ahead_behind and not entry.detached:
133
+ counts = _safe_run(
134
+ ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], real, timeout
135
+ )
136
+ if counts is not None and counts.ok:
137
+ parsed = _parse_ahead_behind(counts.text())
138
+ if parsed is not None:
139
+ upstream = True
140
+ ahead, behind = parsed
141
+ stamp = _safe_run(["log", "-1", "--format=%ct", "HEAD"], real, timeout)
142
+ if stamp is not None and stamp.ok and stamp.text().strip().isdigit():
143
+ last_commit = datetime.fromtimestamp(int(stamp.text().strip()), UTC)
144
+ latest = max((t for t in (activity, last_commit) if t is not None), default=None)
145
+ if probe and latest is not None and now - latest > stale_after:
146
+ reasons.append("inactive")
147
+ return WorktreeSnapshot(
148
+ path=entry.path,
149
+ real_path=real,
150
+ worktree_id=worktree_id_for(real) if real is not None else None,
151
+ is_main=is_main,
152
+ exists=exists,
153
+ branch=entry.branch,
154
+ head=entry.head,
155
+ detached=entry.detached,
156
+ bare=entry.bare,
157
+ locked=entry.locked,
158
+ prunable=entry.prunable,
159
+ dirty_count=dirty,
160
+ upstream=upstream,
161
+ ahead=ahead,
162
+ behind=behind,
163
+ last_commit_ts=last_commit,
164
+ stale=bool(reasons),
165
+ stale_reasons=tuple(reasons),
166
+ error=error,
167
+ )
168
+
169
+
170
+ def collect( # noqa: PLR0913
171
+ repo: str,
172
+ *,
173
+ now: datetime,
174
+ stale_after_hours: int = DEFAULT_STALE_AFTER_HOURS,
175
+ activity: Mapping[str, datetime] | None = None,
176
+ timeout: float = DEFAULT_TIMEOUT_S,
177
+ ahead_behind: bool = True,
178
+ max_workers: int = 6,
179
+ ) -> GitSnapshot:
180
+ """Snapshot every worktree of the repo containing ``repo``. Never raises.
181
+
182
+ ``activity`` maps ``worktree_id`` to the last observed hook activity, used only for the
183
+ ``inactive`` stale reason. ``now`` is injected for deterministic tests.
184
+ """
185
+ now = now.astimezone(UTC)
186
+ try:
187
+ common = git_common_dir(repo, timeout=timeout)
188
+ except NotAGitRepo as exc:
189
+ return _empty(now, f"not_a_git_repo: {exc}")
190
+ except GitUnavailable:
191
+ return _empty(now, "git_unavailable")
192
+ start = os.path.realpath(repo)
193
+ try:
194
+ listing = run_git(["worktree", "list", "--porcelain", "-z"], start, timeout=timeout)
195
+ nul = True
196
+ if listing is None or not listing.ok:
197
+ listing = run_git(["worktree", "list", "--porcelain"], start, timeout=timeout)
198
+ nul = False
199
+ except GitUnavailable:
200
+ return _empty(now, "git_unavailable", common)
201
+ if listing is None or not listing.ok:
202
+ return _empty(now, "worktree_list_failed", common)
203
+ entries = parse_porcelain(listing.text(), nul=nul)
204
+ stale_after = timedelta(hours=stale_after_hours)
205
+ activity = activity or {}
206
+
207
+ def probe(item: tuple[int, WorktreeEntry]) -> WorktreeSnapshot:
208
+ index, entry = item
209
+ real = os.path.realpath(entry.path) if os.path.isdir(entry.path) else None
210
+ wt_id = worktree_id_for(real) if real is not None else None
211
+ return _probe(
212
+ entry,
213
+ is_main=index == 0,
214
+ now=now,
215
+ stale_after=stale_after,
216
+ activity=activity.get(wt_id) if wt_id is not None else None,
217
+ timeout=timeout,
218
+ ahead_behind=ahead_behind,
219
+ )
220
+
221
+ if not entries:
222
+ return GitSnapshot(True, None, common, now, ())
223
+ with ThreadPoolExecutor(max_workers=max(1, min(max_workers, len(entries)))) as pool:
224
+ snapshots = list(pool.map(probe, enumerate(entries)))
225
+ snapshots.sort(key=lambda s: (not s.is_main, s.path))
226
+ return GitSnapshot(True, None, common, now, tuple(snapshots))