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,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]