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,305 @@
1
+ """Merge CursorFleet's hook entries into ``.cursor/hooks.json`` and take them out again.
2
+
3
+ Design:
4
+
5
+ * Every registered hook runs exactly ``cursorfleet-hook`` with no arguments (the
6
+ hook name comes from ``hook_event_name`` on stdin) and a short ``timeout``.
7
+ There is no ``failClosed``: a missing binary or a crash must never block the
8
+ user (observe-only; fail open).
9
+ * Only hooks in ``ALLOWED_V01_HOOKS`` may be emitted. ``assert_registrable`` is
10
+ called on every code path that builds entries, so a forbidden hook name raises
11
+ before anything is written (ADR 0004).
12
+ * No ``matcher`` is written: every hook matches everything. Narrowing is
13
+ PROVISIONAL territory (ADR 0001 A3: matchers target tool types, subagent types
14
+ or command text, whose exact values for custom ``cf-*`` subagents are unverified),
15
+ and a wrong matcher would silently lose events. Revisit after live capture.
16
+ * User entries, other events, other top-level keys and key order are preserved.
17
+ Formatting (indent, separators, trailing newline) is detected and reproduced.
18
+ * Entries are identified as ours by ``command == "cursorfleet-hook"``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from collections.abc import Iterable, Mapping
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from pydantic import BaseModel, ConfigDict
29
+
30
+ from cursorfleet.adapters.cursor.hook_policy import ALLOWED_V01_HOOKS, FORBIDDEN_HOOKS
31
+ from cursorfleet.adapters.cursor.templates import read_template
32
+
33
+ HOOK_COMMAND = "cursorfleet-hook"
34
+ HOOK_TIMEOUT_S = 5
35
+ SUPPORTED_VERSION = 1
36
+ Json = Any
37
+
38
+
39
+ class HooksJsonError(ValueError):
40
+ """``hooks.json`` cannot be merged safely (invalid JSON or unexpected structure)."""
41
+
42
+
43
+ class JsonStyle(BaseModel):
44
+ """How a JSON file was formatted, so we can write it back the same way."""
45
+
46
+ model_config = ConfigDict(extra="forbid", frozen=True)
47
+
48
+ indent: int | None = 2 # None = single line; -1 = tab
49
+ compact: bool = False # separators (",", ":") when indent is None
50
+ ensure_ascii: bool = False
51
+ trailing_newline: bool = True
52
+ crlf: bool = False
53
+
54
+
55
+ DEFAULT_STYLE = JsonStyle()
56
+
57
+
58
+ def dumps(data: Json, style: JsonStyle) -> str:
59
+ indent: int | str | None = "\t" if style.indent == -1 else style.indent
60
+ separators = (",", ":") if style.indent is None and style.compact else None
61
+ if indent is not None:
62
+ separators = (",", ": ")
63
+ text = json.dumps(data, indent=indent, separators=separators, ensure_ascii=style.ensure_ascii)
64
+ text += "\n" if style.trailing_newline else ""
65
+ return text.replace("\n", "\r\n") if style.crlf else text
66
+
67
+
68
+ def _candidate_styles(trailing: bool) -> Iterable[JsonStyle]:
69
+ for ascii_only in (False, True):
70
+ for indent, compact in ((2, False), (4, False), (-1, False), (None, False), (None, True)):
71
+ yield JsonStyle(
72
+ indent=indent,
73
+ compact=compact,
74
+ ensure_ascii=ascii_only,
75
+ trailing_newline=trailing,
76
+ )
77
+
78
+
79
+ def detect_style(text: str, data: Json) -> tuple[JsonStyle, bool]:
80
+ """Return ``(style, reproducible)``; reproducible means ``dumps(data, style) == text``."""
81
+ crlf = "\r\n" in text
82
+ normalized = text.replace("\r\n", "\n")
83
+ trailing = normalized.endswith("\n")
84
+ for candidate in _candidate_styles(trailing):
85
+ style = candidate.model_copy(update={"crlf": crlf})
86
+ if dumps(data, style) == text:
87
+ return style, True
88
+ return JsonStyle(trailing_newline=trailing, crlf=crlf), False
89
+
90
+
91
+ def managed_entry() -> dict[str, Json]:
92
+ """The single entry we register per hook, from ``templates/cursor/hooks/entry.json``."""
93
+ entry = json.loads(read_template("hooks/entry.json"))
94
+ if entry.get("command") != HOOK_COMMAND or "failClosed" in entry or "matcher" in entry:
95
+ msg = "hooks/entry.json must be exactly the cursorfleet-hook command with a timeout"
96
+ raise HooksJsonError(msg)
97
+ return {str(k): v for k, v in entry.items()}
98
+
99
+
100
+ def assert_registrable(events: Iterable[str]) -> tuple[str, ...]:
101
+ """Raise unless every event is on the v0.1 allowlist and none is forbidden."""
102
+ ordered = tuple(events)
103
+ for name in ordered:
104
+ if name in FORBIDDEN_HOOKS:
105
+ msg = f"refusing to register forbidden hook {name!r} (ADR 0004)"
106
+ raise HooksJsonError(msg)
107
+ if name not in ALLOWED_V01_HOOKS:
108
+ msg = f"refusing to register hook {name!r}: not in ALLOWED_V01_HOOKS"
109
+ raise HooksJsonError(msg)
110
+ return ordered
111
+
112
+
113
+ def is_ours(entry: object) -> bool:
114
+ return isinstance(entry, dict) and entry.get("command") == HOOK_COMMAND
115
+
116
+
117
+ @dataclass
118
+ class HooksState:
119
+ """What a merge did, recorded in the lockfile so uninstall can undo exactly it."""
120
+
121
+ added_version: bool = False
122
+ added_hooks_key: bool = False
123
+ added_events: list[str] = field(default_factory=list)
124
+ entries: dict[str, dict[str, Json]] = field(default_factory=dict)
125
+
126
+
127
+ @dataclass
128
+ class MergeResult:
129
+ text: str
130
+ state: HooksState
131
+ style: JsonStyle
132
+ reproducible: bool
133
+ conflicts: list[str]
134
+
135
+
136
+ def _parse(text: str) -> dict[str, Json]:
137
+ try:
138
+ data = json.loads(text)
139
+ except json.JSONDecodeError as exc:
140
+ msg = f"invalid JSON ({exc.msg} at line {exc.lineno})"
141
+ raise HooksJsonError(msg) from exc
142
+ if not isinstance(data, dict):
143
+ msg = "top level must be a JSON object"
144
+ raise HooksJsonError(msg)
145
+ hooks = data.get("hooks")
146
+ if hooks is not None:
147
+ if not isinstance(hooks, dict):
148
+ msg = '"hooks" must be an object'
149
+ raise HooksJsonError(msg)
150
+ for event, entries in hooks.items():
151
+ if not isinstance(entries, list):
152
+ msg = f"hooks.{event} must be a list"
153
+ raise HooksJsonError(msg)
154
+ version = data.get("version")
155
+ if version is not None and version != SUPPORTED_VERSION:
156
+ msg = f'unsupported hooks.json "version": {version!r} (expected {SUPPORTED_VERSION})'
157
+ raise HooksJsonError(msg)
158
+ return data
159
+
160
+
161
+ def merge( # noqa: PLR0912
162
+ existing: str | None,
163
+ events: Iterable[str],
164
+ previous: Mapping[str, Mapping[str, Json]] | None = None,
165
+ previous_state: HooksState | None = None,
166
+ ) -> MergeResult:
167
+ """Return hooks.json text containing our entries for ``events``.
168
+
169
+ ``previous`` are the entries recorded by an earlier install (event -> entry).
170
+ Our entries not in ``events`` are removed if they are unmodified; an entry of
171
+ ours that was modified by the user is a conflict, not silently overwritten.
172
+ """
173
+ wanted = assert_registrable(events)
174
+ entry = managed_entry()
175
+ previous = previous or {}
176
+ state = HooksState(
177
+ added_version=previous_state.added_version if previous_state else False,
178
+ added_hooks_key=previous_state.added_hooks_key if previous_state else False,
179
+ added_events=list(previous_state.added_events) if previous_state else [],
180
+ )
181
+ conflicts: list[str] = []
182
+
183
+ if existing is None:
184
+ data: dict[str, Json] = {"version": SUPPORTED_VERSION, "hooks": {}}
185
+ style, reproducible = DEFAULT_STYLE, True
186
+ state.added_version = state.added_hooks_key = True
187
+ else:
188
+ data = _parse(existing)
189
+ style, reproducible = detect_style(existing, data)
190
+ if "version" not in data:
191
+ data = {"version": SUPPORTED_VERSION, **data}
192
+ state.added_version = True
193
+ if "hooks" not in data:
194
+ data["hooks"] = {}
195
+ state.added_hooks_key = True
196
+
197
+ hooks: dict[str, list[Json]] = data["hooks"]
198
+ for event in list(hooks):
199
+ ours_idx = [i for i, e in enumerate(hooks[event]) if is_ours(e)]
200
+ if len(ours_idx) > 1:
201
+ conflicts.append(f"hooks.{event} has more than one cursorfleet-hook entry")
202
+ continue
203
+ recorded = previous.get(event)
204
+ if event in wanted:
205
+ if ours_idx:
206
+ current = hooks[event][ours_idx[0]]
207
+ if current != entry:
208
+ if recorded is not None and current == recorded:
209
+ hooks[event][ours_idx[0]] = dict(entry)
210
+ else:
211
+ conflicts.append(
212
+ f"hooks.{event} has a modified cursorfleet-hook entry; "
213
+ "restore it or run `cursorfleet uninstall --force`"
214
+ )
215
+ continue
216
+ # Event not wanted: drop our stale entry if unmodified.
217
+ if ours_idx:
218
+ current = hooks[event][ours_idx[0]]
219
+ if recorded is not None and current == recorded:
220
+ del hooks[event][ours_idx[0]]
221
+ if not hooks[event] and event in state.added_events:
222
+ del hooks[event]
223
+ state.added_events.remove(event)
224
+ else:
225
+ conflicts.append(f"hooks.{event} has a cursorfleet-hook entry we did not record")
226
+
227
+ for event in wanted:
228
+ if event not in hooks:
229
+ hooks[event] = []
230
+ state.added_events.append(event)
231
+ if not any(is_ours(e) for e in hooks[event]):
232
+ hooks[event].append(dict(entry))
233
+ state.entries[event] = dict(entry)
234
+
235
+ return MergeResult(
236
+ text=dumps(data, style),
237
+ state=state,
238
+ style=style,
239
+ reproducible=reproducible,
240
+ conflicts=conflicts,
241
+ )
242
+
243
+
244
+ @dataclass
245
+ class UnmergeResult:
246
+ text: str
247
+ drift: list[str]
248
+ now_empty: bool # nothing but what we created remains
249
+
250
+
251
+ def unmerge(
252
+ text: str,
253
+ state: HooksState,
254
+ style: JsonStyle,
255
+ *,
256
+ created: bool,
257
+ force: bool = False,
258
+ ) -> UnmergeResult:
259
+ """Remove our entries (and only what we added) from ``text``."""
260
+ data = _parse(text)
261
+ drift: list[str] = []
262
+ hooks = data.get("hooks")
263
+ if isinstance(hooks, dict):
264
+ for event, recorded in state.entries.items():
265
+ entries = hooks.get(event)
266
+ if not isinstance(entries, list):
267
+ continue
268
+ idx = [i for i, e in enumerate(entries) if is_ours(e)]
269
+ for i in reversed(idx):
270
+ if entries[i] != recorded and not force:
271
+ drift.append(f"hooks.{event}: cursorfleet-hook entry was modified")
272
+ continue
273
+ del entries[i]
274
+ if not entries and event in state.added_events:
275
+ del hooks[event]
276
+ if not hooks and state.added_hooks_key:
277
+ del data["hooks"]
278
+ if state.added_version and data.get("version") == SUPPORTED_VERSION:
279
+ del data["version"]
280
+ empty = created and not drift and data == {}
281
+ return UnmergeResult(text=dumps(data, style), drift=drift, now_empty=empty)
282
+
283
+
284
+ def effective_events(text: str) -> dict[str, list[dict[str, Json]]]:
285
+ """Parse any hooks.json into ``event -> [entries]``; non-object entries are skipped."""
286
+ data = _parse_lenient(text)
287
+ hooks = data.get("hooks")
288
+ result: dict[str, list[dict[str, Json]]] = {}
289
+ if isinstance(hooks, dict):
290
+ for event, entries in hooks.items():
291
+ if isinstance(entries, list):
292
+ result[str(event)] = [e for e in entries if isinstance(e, dict)]
293
+ return result
294
+
295
+
296
+ def _parse_lenient(text: str) -> dict[str, Json]:
297
+ try:
298
+ data = json.loads(text)
299
+ except json.JSONDecodeError as exc:
300
+ msg = f"invalid JSON ({exc.msg} at line {exc.lineno})"
301
+ raise HooksJsonError(msg) from exc
302
+ if not isinstance(data, dict):
303
+ msg = "top level must be a JSON object"
304
+ raise HooksJsonError(msg)
305
+ return data