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
|
@@ -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))
|