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,154 @@
|
|
|
1
|
+
"""Model of ``.cursorfleet/config.toml`` (schema_version "1.0"); source of config.schema.json.
|
|
2
|
+
|
|
3
|
+
Config is data, never code: nothing in it is executed. There is intentionally no
|
|
4
|
+
setting that stores prompts, thinking, responses, file contents or command
|
|
5
|
+
output, and none that enables a network call or a forbidden hook.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, field_validator
|
|
13
|
+
|
|
14
|
+
from cursorfleet.events.kinds import COMMAND_DISPLAY_MAX_CHARS
|
|
15
|
+
from cursorfleet.events.pathcheck import check_not_in_git_dir, check_relative_posix
|
|
16
|
+
|
|
17
|
+
# Must equal ALLOWED_V01_HOOKS (asserted by a unit test). Forbidden hooks are
|
|
18
|
+
# not representable, so a config file can never ask the installer to emit them.
|
|
19
|
+
HookName = Literal[
|
|
20
|
+
"sessionStart",
|
|
21
|
+
"sessionEnd",
|
|
22
|
+
"preToolUse",
|
|
23
|
+
"postToolUse",
|
|
24
|
+
"postToolUseFailure",
|
|
25
|
+
"subagentStart",
|
|
26
|
+
"subagentStop",
|
|
27
|
+
"beforeShellExecution",
|
|
28
|
+
"afterShellExecution",
|
|
29
|
+
"afterFileEdit",
|
|
30
|
+
"preCompact",
|
|
31
|
+
"stop",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_DEFAULT_HOOKS: tuple[HookName, ...] = (
|
|
35
|
+
"sessionStart",
|
|
36
|
+
"sessionEnd",
|
|
37
|
+
"preToolUse",
|
|
38
|
+
"postToolUse",
|
|
39
|
+
"postToolUseFailure",
|
|
40
|
+
"subagentStart",
|
|
41
|
+
"subagentStop",
|
|
42
|
+
"beforeShellExecution",
|
|
43
|
+
"afterShellExecution",
|
|
44
|
+
"afterFileEdit",
|
|
45
|
+
"preCompact",
|
|
46
|
+
"stop",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_STRICT = ConfigDict(extra="forbid", frozen=True)
|
|
50
|
+
_VersionStr = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._+\-]{0,63}$")]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _relative(value: str) -> str:
|
|
54
|
+
return check_not_in_git_dir(check_relative_posix(value))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RetentionConfig(BaseModel):
|
|
58
|
+
"""Spool retention. Defaults: 14 days or 50 MB per session, whichever comes first."""
|
|
59
|
+
|
|
60
|
+
model_config = _STRICT
|
|
61
|
+
|
|
62
|
+
max_age_days: int = Field(default=14, ge=1, le=365)
|
|
63
|
+
max_session_mb: int = Field(default=50, ge=1, le=1024)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PrivacyConfig(BaseModel):
|
|
67
|
+
"""Knobs can only reduce what is stored; they cannot add content fields."""
|
|
68
|
+
|
|
69
|
+
model_config = _STRICT
|
|
70
|
+
|
|
71
|
+
store_command_display: bool = False
|
|
72
|
+
command_display_max_chars: int = Field(default=COMMAND_DISPLAY_MAX_CHARS, ge=20, le=200)
|
|
73
|
+
hash_commands: bool = True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HooksConfig(BaseModel):
|
|
77
|
+
"""Which passive hooks the installer registers (subset of the 12 allowed)."""
|
|
78
|
+
|
|
79
|
+
model_config = _STRICT
|
|
80
|
+
|
|
81
|
+
enabled: tuple[HookName, ...] = _DEFAULT_HOOKS
|
|
82
|
+
|
|
83
|
+
@field_validator("enabled")
|
|
84
|
+
@classmethod
|
|
85
|
+
def _unique(cls, value: tuple[HookName, ...]) -> tuple[HookName, ...]:
|
|
86
|
+
if len(set(value)) != len(value):
|
|
87
|
+
msg = "hooks.enabled must not contain duplicates"
|
|
88
|
+
raise ValueError(msg)
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GitConfig(BaseModel):
|
|
93
|
+
"""Read-only git/worktree collector settings."""
|
|
94
|
+
|
|
95
|
+
model_config = _STRICT
|
|
96
|
+
|
|
97
|
+
refresh_interval_s: int = Field(default=5, ge=1, le=3600)
|
|
98
|
+
stale_after_hours: int = Field(default=72, ge=1, le=24 * 365)
|
|
99
|
+
ahead_behind: bool = True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TuiConfig(BaseModel):
|
|
103
|
+
model_config = _STRICT
|
|
104
|
+
|
|
105
|
+
refresh_interval_ms: int = Field(default=500, ge=100, le=10_000)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CursorConfig(BaseModel):
|
|
109
|
+
"""Cursor-specific settings."""
|
|
110
|
+
|
|
111
|
+
model_config = _STRICT
|
|
112
|
+
|
|
113
|
+
validated_versions: tuple[_VersionStr, ...] = Field(
|
|
114
|
+
default=(),
|
|
115
|
+
description="Cursor versions you have verified; `doctor` warns on others. "
|
|
116
|
+
"Empty until a live capture has been reviewed (ADR 0001).",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class WorkConfig(BaseModel):
|
|
121
|
+
model_config = _STRICT
|
|
122
|
+
|
|
123
|
+
dir: Annotated[str, AfterValidator(_relative)] = Field(
|
|
124
|
+
default=".cursorfleet/work",
|
|
125
|
+
description="Workspace-relative directory holding agent-declared Markdown artifacts.",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class FleetConfig(BaseModel):
|
|
130
|
+
"""Root of ``.cursorfleet/config.toml``."""
|
|
131
|
+
|
|
132
|
+
model_config = _STRICT
|
|
133
|
+
|
|
134
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
135
|
+
retention: RetentionConfig = Field(default_factory=RetentionConfig)
|
|
136
|
+
privacy: PrivacyConfig = Field(default_factory=PrivacyConfig)
|
|
137
|
+
hooks: HooksConfig = Field(default_factory=HooksConfig)
|
|
138
|
+
git: GitConfig = Field(default_factory=GitConfig)
|
|
139
|
+
tui: TuiConfig = Field(default_factory=TuiConfig)
|
|
140
|
+
cursor: CursorConfig = Field(default_factory=CursorConfig)
|
|
141
|
+
work: WorkConfig = Field(default_factory=WorkConfig)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
__all__ = [
|
|
145
|
+
"CursorConfig",
|
|
146
|
+
"FleetConfig",
|
|
147
|
+
"GitConfig",
|
|
148
|
+
"HookName",
|
|
149
|
+
"HooksConfig",
|
|
150
|
+
"PrivacyConfig",
|
|
151
|
+
"RetentionConfig",
|
|
152
|
+
"TuiConfig",
|
|
153
|
+
"WorkConfig",
|
|
154
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Model of ``.cursorfleet/roster.toml`` (schema_version "1.0") and the default roster.
|
|
2
|
+
|
|
3
|
+
The roster is the source of truth. Generators (M1) render ``.cursor/agents/*.md``,
|
|
4
|
+
rules and skills from it; roster semantics are never stored only in Markdown.
|
|
5
|
+
|
|
6
|
+
Cursor subagent frontmatter supports only name, description, model, readonly and
|
|
7
|
+
is_background (ADR 0001, A7). Everything under ``capability_notes`` is
|
|
8
|
+
DOCUMENTATION ONLY: Cursor cannot enforce per-agent tools, paths, network or git
|
|
9
|
+
capability, and v0.1 does not either.
|
|
10
|
+
|
|
11
|
+
PROVISIONAL (ADR 0001 Q2): how a custom subagent name appears in
|
|
12
|
+
``subagent_type``. ``Roster.role_for_subagent_type`` degrades to ``None`` for
|
|
13
|
+
anything unrecognised so callers fall back to the raw value.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from typing import Annotated, Literal, Self
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
22
|
+
|
|
23
|
+
_STRICT = ConfigDict(extra="forbid", frozen=True)
|
|
24
|
+
|
|
25
|
+
RosterId = Annotated[str, Field(pattern=r"^[a-z][a-z0-9\-]{1,39}$")]
|
|
26
|
+
Model = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/\[\]=,\- ]{0,127}$")]
|
|
27
|
+
Note = Annotated[str, Field(min_length=1, max_length=200)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Role(StrEnum):
|
|
31
|
+
"""Workflow function. Several agents may share one role (alpha/beta implementers)."""
|
|
32
|
+
|
|
33
|
+
COORDINATOR = "coordinator"
|
|
34
|
+
ARCHITECT = "architect"
|
|
35
|
+
SCOUT = "scout"
|
|
36
|
+
IMPLEMENTER = "implementer"
|
|
37
|
+
TESTER = "tester"
|
|
38
|
+
REVIEWER = "reviewer"
|
|
39
|
+
PATCHER = "patcher"
|
|
40
|
+
QA = "qa"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentSpec(BaseModel):
|
|
44
|
+
"""A Cursor subagent rendered to ``.cursor/agents/<name>.md``."""
|
|
45
|
+
|
|
46
|
+
model_config = _STRICT
|
|
47
|
+
|
|
48
|
+
id: RosterId = Field(description="Stable slug; also the value stored in event.agent_role.")
|
|
49
|
+
role: Role
|
|
50
|
+
description: str = Field(
|
|
51
|
+
min_length=20,
|
|
52
|
+
max_length=1024,
|
|
53
|
+
description="Delegation hint Cursor reads; keep it sharp (when to use, when not to).",
|
|
54
|
+
)
|
|
55
|
+
readonly: bool = Field(description="Cursor's only permission-like switch; coarse.")
|
|
56
|
+
model: Model = "inherit"
|
|
57
|
+
is_background: bool = False
|
|
58
|
+
enabled: bool = True
|
|
59
|
+
capability_notes: tuple[Note, ...] = Field(
|
|
60
|
+
default=(),
|
|
61
|
+
max_length=10,
|
|
62
|
+
description="DOCUMENTATION ONLY. Not enforced by Cursor or by CursorFleet v0.1.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@model_validator(mode="after")
|
|
66
|
+
def _not_coordinator(self) -> Self:
|
|
67
|
+
if self.role is Role.COORDINATOR:
|
|
68
|
+
msg = "the coordinator is the main agent, not a subagent; use roster.coordinator"
|
|
69
|
+
raise ValueError(msg)
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CoordinatorSpec(BaseModel):
|
|
74
|
+
"""The main agent, driven by a skill and/or rule. Never rendered as a subagent file.
|
|
75
|
+
|
|
76
|
+
Design choice (ADR 0001 A7), not a platform limit.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
model_config = _STRICT
|
|
80
|
+
|
|
81
|
+
id: Literal["coordinator"] = "coordinator"
|
|
82
|
+
role: Literal[Role.COORDINATOR] = Role.COORDINATOR
|
|
83
|
+
description: str = Field(min_length=20, max_length=1024)
|
|
84
|
+
delivery: Literal["skill", "rule", "both"] = "skill"
|
|
85
|
+
enabled: bool = True
|
|
86
|
+
capability_notes: tuple[Note, ...] = Field(default=(), max_length=10)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Roster(BaseModel):
|
|
90
|
+
"""Root of ``.cursorfleet/roster.toml``."""
|
|
91
|
+
|
|
92
|
+
model_config = _STRICT
|
|
93
|
+
|
|
94
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
95
|
+
name_prefix: Annotated[str, Field(pattern=r"^([a-z][a-z0-9]{0,15}-)?$")] = Field(
|
|
96
|
+
default="cf-",
|
|
97
|
+
description="Prefix for generated Cursor subagent names. PROVISIONAL (ADR 0001 Q2).",
|
|
98
|
+
)
|
|
99
|
+
coordinator: CoordinatorSpec
|
|
100
|
+
agents: tuple[AgentSpec, ...] = Field(min_length=1, max_length=32)
|
|
101
|
+
|
|
102
|
+
@model_validator(mode="after")
|
|
103
|
+
def _unique_ids(self) -> Self:
|
|
104
|
+
ids = [self.coordinator.id, *(a.id for a in self.agents)]
|
|
105
|
+
if len(set(ids)) != len(ids):
|
|
106
|
+
msg = "roster ids must be unique and must not reuse 'coordinator'"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def enabled_agents(self) -> tuple[AgentSpec, ...]:
|
|
111
|
+
return tuple(a for a in self.agents if a.enabled)
|
|
112
|
+
|
|
113
|
+
def cursor_name(self, agent_id: str) -> str:
|
|
114
|
+
"""Subagent name written to ``.cursor/agents`` frontmatter and filename."""
|
|
115
|
+
return f"{self.name_prefix}{agent_id}"
|
|
116
|
+
|
|
117
|
+
def role_for_subagent_type(self, subagent_type: str) -> str | None:
|
|
118
|
+
"""Map a hook ``subagent_type`` to a roster id, or ``None`` if unrecognised.
|
|
119
|
+
|
|
120
|
+
Accepts the prefixed Cursor name or the bare id. Built-ins such as
|
|
121
|
+
``generalPurpose`` and ``explore`` return ``None``; callers then keep the
|
|
122
|
+
raw value as ``agent_role``.
|
|
123
|
+
"""
|
|
124
|
+
bare = subagent_type.removeprefix(self.name_prefix) if self.name_prefix else subagent_type
|
|
125
|
+
for agent in self.agents:
|
|
126
|
+
if bare == agent.id:
|
|
127
|
+
return agent.id
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def default_roster() -> Roster:
|
|
132
|
+
"""Default roster: the coordinator plus 7 enabled subagents; implementer-beta is off."""
|
|
133
|
+
return Roster(
|
|
134
|
+
coordinator=CoordinatorSpec(
|
|
135
|
+
description=(
|
|
136
|
+
"Main-agent playbook: split work, delegate to roster subagents, track handoffs "
|
|
137
|
+
"and blockers in .cursorfleet/work/, and never declare done on its own."
|
|
138
|
+
),
|
|
139
|
+
capability_notes=(
|
|
140
|
+
"Runs as the main agent via a skill; it is not a subagent file.",
|
|
141
|
+
"Asks for isolated worktrees in the prompt when parallel implementers are used.",
|
|
142
|
+
),
|
|
143
|
+
),
|
|
144
|
+
agents=(
|
|
145
|
+
AgentSpec(
|
|
146
|
+
id="architect",
|
|
147
|
+
role=Role.ARCHITECT,
|
|
148
|
+
description=(
|
|
149
|
+
"Use for design and planning before code changes: module boundaries, "
|
|
150
|
+
"interfaces, trade-offs, a written plan. Do not use for implementation."
|
|
151
|
+
),
|
|
152
|
+
readonly=True,
|
|
153
|
+
capability_notes=("Intended to read the repo and write plan artifacts only.",),
|
|
154
|
+
),
|
|
155
|
+
AgentSpec(
|
|
156
|
+
id="repo-scout",
|
|
157
|
+
role=Role.SCOUT,
|
|
158
|
+
description=(
|
|
159
|
+
"Use to map unfamiliar code: find files, trace call paths, summarize "
|
|
160
|
+
"conventions. Read-only exploration; do not use to change code."
|
|
161
|
+
),
|
|
162
|
+
readonly=True,
|
|
163
|
+
capability_notes=("Intended to be read-only; cheap model is acceptable.",),
|
|
164
|
+
),
|
|
165
|
+
AgentSpec(
|
|
166
|
+
id="implementer-alpha",
|
|
167
|
+
role=Role.IMPLEMENTER,
|
|
168
|
+
description=(
|
|
169
|
+
"Use to implement a scoped, already-planned change in the working tree and "
|
|
170
|
+
"report a handoff. Do not use for design or for reviewing its own work."
|
|
171
|
+
),
|
|
172
|
+
readonly=False,
|
|
173
|
+
capability_notes=(
|
|
174
|
+
"Intended write scope: files named in the plan.",
|
|
175
|
+
"Ask for an isolated worktree when running beside another implementer.",
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
AgentSpec(
|
|
179
|
+
id="test-engineer",
|
|
180
|
+
role=Role.TESTER,
|
|
181
|
+
description=(
|
|
182
|
+
"Use to write and run tests for a change and report results. "
|
|
183
|
+
"Do not use to change production code beyond testability hooks."
|
|
184
|
+
),
|
|
185
|
+
readonly=False,
|
|
186
|
+
capability_notes=("Intended write scope: test directories and fixtures.",),
|
|
187
|
+
),
|
|
188
|
+
AgentSpec(
|
|
189
|
+
id="reviewer",
|
|
190
|
+
role=Role.REVIEWER,
|
|
191
|
+
description=(
|
|
192
|
+
"Use for independent review of a diff: correctness, security, privacy, "
|
|
193
|
+
"conventions. Read-only; never fixes issues itself, hands them to the patcher."
|
|
194
|
+
),
|
|
195
|
+
readonly=True,
|
|
196
|
+
capability_notes=(
|
|
197
|
+
"Pin a different model family than the implementer for independence; "
|
|
198
|
+
"Cursor may silently fall back to a compatible model.",
|
|
199
|
+
),
|
|
200
|
+
),
|
|
201
|
+
AgentSpec(
|
|
202
|
+
id="patch-engineer",
|
|
203
|
+
role=Role.PATCHER,
|
|
204
|
+
description=(
|
|
205
|
+
"Use to apply review findings with minimal, targeted fixes and re-report. "
|
|
206
|
+
"Do not use for new features or refactors."
|
|
207
|
+
),
|
|
208
|
+
readonly=False,
|
|
209
|
+
capability_notes=("Intended write scope: files cited in review findings.",),
|
|
210
|
+
),
|
|
211
|
+
AgentSpec(
|
|
212
|
+
id="qa-release",
|
|
213
|
+
role=Role.QA,
|
|
214
|
+
description=(
|
|
215
|
+
"Use to verify a finished change: clean install, lint, types, tests, build, "
|
|
216
|
+
"release notes. Reports evidence; does not edit source."
|
|
217
|
+
),
|
|
218
|
+
readonly=False,
|
|
219
|
+
capability_notes=(
|
|
220
|
+
"Needs shell access (so not readonly) but is intended not to edit source.",
|
|
221
|
+
"Cursor has no clean-environment primitive; v0.1 cannot guarantee one.",
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
AgentSpec(
|
|
225
|
+
id="implementer-beta",
|
|
226
|
+
role=Role.IMPLEMENTER,
|
|
227
|
+
description=(
|
|
228
|
+
"Optional second implementer for parallel work in its own worktree. "
|
|
229
|
+
"Disabled by default; enable only when tasks are truly independent."
|
|
230
|
+
),
|
|
231
|
+
readonly=False,
|
|
232
|
+
enabled=False,
|
|
233
|
+
capability_notes=("Parallel work without isolation can overwrite alpha's edits.",),
|
|
234
|
+
),
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
__all__ = [
|
|
240
|
+
"AgentSpec",
|
|
241
|
+
"CoordinatorSpec",
|
|
242
|
+
"Role",
|
|
243
|
+
"Roster",
|
|
244
|
+
"default_roster",
|
|
245
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Normalized event model for CursorFleet.
|
|
2
|
+
|
|
3
|
+
Importing this package is cheap and stdlib-only. Pydantic models live in
|
|
4
|
+
``cursorfleet.events.models`` and are re-exported lazily (PEP 562) so the hook
|
|
5
|
+
hot path can import ``cursorfleet.events.kinds``/``ids``/``forbidden`` without
|
|
6
|
+
pulling in pydantic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib import import_module
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False # not ``typing.TYPE_CHECKING``: importing typing costs ~2 ms per hook
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from cursorfleet.events.models import (
|
|
16
|
+
Event,
|
|
17
|
+
GateRef,
|
|
18
|
+
Metrics,
|
|
19
|
+
SanitizedCommand,
|
|
20
|
+
SanitizedPath,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_LAZY = {
|
|
24
|
+
"Event": "cursorfleet.events.models",
|
|
25
|
+
"GateRef": "cursorfleet.events.models",
|
|
26
|
+
"Metrics": "cursorfleet.events.models",
|
|
27
|
+
"SanitizedCommand": "cursorfleet.events.models",
|
|
28
|
+
"SanitizedPath": "cursorfleet.events.models",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
__all__ = ["Event", "GateRef", "Metrics", "SanitizedCommand", "SanitizedPath"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __getattr__(name: str) -> object:
|
|
35
|
+
module = _LAZY.get(name)
|
|
36
|
+
if module is None:
|
|
37
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
38
|
+
raise AttributeError(msg)
|
|
39
|
+
return getattr(import_module(module), name)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Field names that must never appear in a persisted event. Stdlib only.
|
|
2
|
+
|
|
3
|
+
These are Cursor payload fields (or obvious aliases) carrying prompts,
|
|
4
|
+
thinking, responses, file contents, command output, environment, emails or
|
|
5
|
+
transcript locations. The event models reject every one of them (extra="forbid"),
|
|
6
|
+
and security tests assert none is a model field.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
FORBIDDEN_EVENT_FIELD_NAMES: frozenset[str] = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"prompt",
|
|
14
|
+
"thought",
|
|
15
|
+
"thinking",
|
|
16
|
+
"response",
|
|
17
|
+
"text",
|
|
18
|
+
"message",
|
|
19
|
+
"content",
|
|
20
|
+
"contents",
|
|
21
|
+
"file_content",
|
|
22
|
+
"file_contents",
|
|
23
|
+
"output",
|
|
24
|
+
"stdout",
|
|
25
|
+
"stderr",
|
|
26
|
+
"tool_input",
|
|
27
|
+
"tool_output",
|
|
28
|
+
"error_message",
|
|
29
|
+
"edits",
|
|
30
|
+
"task",
|
|
31
|
+
"summary",
|
|
32
|
+
"description",
|
|
33
|
+
"attachments",
|
|
34
|
+
"env",
|
|
35
|
+
"environment",
|
|
36
|
+
"user_email",
|
|
37
|
+
"email",
|
|
38
|
+
"transcript_path",
|
|
39
|
+
"agent_transcript_path",
|
|
40
|
+
"workspace_roots",
|
|
41
|
+
"cwd",
|
|
42
|
+
"file_path",
|
|
43
|
+
"modified_files",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Stdlib-only identifier helpers (hot-path safe)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
_CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
9
|
+
|
|
10
|
+
# Single source for the ULID shape; the Pydantic models and tests reuse it.
|
|
11
|
+
ULID_PATTERN = r"^[0-7][0-9A-HJKMNP-TV-Z]{25}$"
|
|
12
|
+
SAFE_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$"
|
|
13
|
+
AGENT_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}(#[A-Za-z0-9][A-Za-z0-9._:\-]{0,127})?$"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _encode(value: int, length: int) -> str:
|
|
17
|
+
chars = []
|
|
18
|
+
for _ in range(length):
|
|
19
|
+
chars.append(_CROCKFORD[value & 31])
|
|
20
|
+
value >>= 5
|
|
21
|
+
return "".join(reversed(chars))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def new_event_id(now_ms: int | None = None, entropy: bytes | None = None) -> str:
|
|
25
|
+
"""Return a 26-char ULID-style id: 48-bit ms timestamp + 80 random bits.
|
|
26
|
+
|
|
27
|
+
Lexicographic order follows time at millisecond resolution. Ids created in
|
|
28
|
+
the same millisecond are not guaranteed to be ordered.
|
|
29
|
+
"""
|
|
30
|
+
ms = time.time_ns() // 1_000_000 if now_ms is None else now_ms
|
|
31
|
+
if not 0 <= ms < 1 << 48:
|
|
32
|
+
msg = "timestamp out of 48-bit range"
|
|
33
|
+
raise ValueError(msg)
|
|
34
|
+
raw = os.urandom(10) if entropy is None else entropy
|
|
35
|
+
if len(raw) != 10:
|
|
36
|
+
msg = "entropy must be exactly 10 bytes"
|
|
37
|
+
raise ValueError(msg)
|
|
38
|
+
return _encode(ms, 10) + _encode(int.from_bytes(raw, "big"), 16)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def derive_agent_id(agent_role: str | None, agent_instance_id: str | None) -> str | None:
|
|
42
|
+
"""Derive ``agent_id``: ``role#instance``; ``None`` when nothing identifies the agent.
|
|
43
|
+
|
|
44
|
+
``#`` is outside the SafeId alphabet, so the result is unambiguous. ``None``
|
|
45
|
+
means "main agent or unattributed" (ADR 0003, PROVISIONAL until Q1 is answered).
|
|
46
|
+
"""
|
|
47
|
+
if agent_role is None and agent_instance_id is None:
|
|
48
|
+
return None
|
|
49
|
+
if agent_instance_id is None:
|
|
50
|
+
return agent_role
|
|
51
|
+
return f"{agent_role or 'unknown'}#{agent_instance_id}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_SAFE_ID_RE_SRC = SAFE_ID_PATTERN
|
|
55
|
+
_FS_SAFE_RE_SRC = r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,127}$"
|
|
56
|
+
_WINDOWS_RESERVED = frozenset(
|
|
57
|
+
{"con", "prn", "aux", "nul"}
|
|
58
|
+
| {f"com{i}" for i in range(1, 10)}
|
|
59
|
+
| {f"lpt{i}" for i in range(1, 10)}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sha256_hex(data: bytes) -> str:
|
|
64
|
+
import hashlib # lazy: hashlib costs ~2 ms of import time on the hot path
|
|
65
|
+
|
|
66
|
+
return hashlib.sha256(data).hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def worktree_id_for(top_level_realpath: str) -> str:
|
|
70
|
+
"""``wt-`` + first 12 hex of SHA-256 of the (normcased) realpath of a worktree top level.
|
|
71
|
+
|
|
72
|
+
Callers must pass ``os.path.realpath(...)`` output. ``normcase`` makes the id stable
|
|
73
|
+
on case-insensitive Windows paths; it is the identity function on POSIX.
|
|
74
|
+
"""
|
|
75
|
+
norm = os.path.normcase(top_level_realpath)
|
|
76
|
+
return "wt-" + _sha256_hex(norm.encode("utf-8", "surrogateescape"))[:12]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def safe_id(value: object, *, max_len: int = 128) -> str | None:
|
|
80
|
+
"""Return ``value`` if it is a valid SafeId, else a stable ``h-<24 hex>`` hash, else None.
|
|
81
|
+
|
|
82
|
+
Non-strings, empty strings and absurdly long strings yield ``None`` (never raise).
|
|
83
|
+
"""
|
|
84
|
+
import re
|
|
85
|
+
|
|
86
|
+
if not isinstance(value, str) or not value or len(value) > 4096:
|
|
87
|
+
return None
|
|
88
|
+
if len(value) <= max_len and re.match(_SAFE_ID_RE_SRC, value):
|
|
89
|
+
return value
|
|
90
|
+
return "h-" + _sha256_hex(value.encode("utf-8", "surrogateescape"))[:24]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fs_name_for(identifier: str) -> str:
|
|
94
|
+
"""Directory/file stem for an id: Windows-, macOS- and Linux-safe, never traversing.
|
|
95
|
+
|
|
96
|
+
Ids that are not already portable (``:`` is illegal on Windows, reserved device
|
|
97
|
+
names, trailing dots, upper case, over-long) are replaced by a stable ``h-<24 hex>`` hash.
|
|
98
|
+
"""
|
|
99
|
+
import re
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
re.match(_FS_SAFE_RE_SRC, identifier)
|
|
103
|
+
and not identifier.endswith(".")
|
|
104
|
+
and identifier.split(".", 1)[0].lower() not in _WINDOWS_RESERVED
|
|
105
|
+
and not identifier.startswith("h-") # keep the hash namespace collision-free
|
|
106
|
+
and identifier == identifier.lower() # case-insensitive filesystems (macOS, Windows)
|
|
107
|
+
):
|
|
108
|
+
return identifier
|
|
109
|
+
return "h-" + _sha256_hex(identifier.encode("utf-8", "surrogateescape"))[:24]
|