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,186 @@
1
+ """Spool line codec and the hot-path appender. Stdlib only, no pathlib/dataclasses/typing.
2
+
3
+ Line format (ADR 0002): ``<crc32 hex8> <space> <event json> "\\n"``, at most 8 KiB,
4
+ written with a single ``O_APPEND`` ``write``. One file per writer:
5
+ ``spool/<session>/<writer>.jsonl``. When a writer file reaches ``ROTATE_BYTES`` it is
6
+ renamed to ``<writer>.<ms>-<pid>.jsonl`` and a fresh file is started.
7
+
8
+ Retention on the hot path (docs/privacy.md): when a session directory reaches its byte
9
+ cap the appender writes a ``.capped`` marker and stops appending for that session
10
+ instead of rotating forever; the indexer / ``events purge`` prune and clear the marker.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import zlib
17
+
18
+ from cursorfleet.events.ids import fs_name_for
19
+ from cursorfleet.state.runtime import (
20
+ FILE_MODE,
21
+ RuntimePaths,
22
+ ensure_runtime_root,
23
+ mkdir_private,
24
+ untrusted_reason,
25
+ write_private_file,
26
+ )
27
+
28
+ MAX_LINE_BYTES = 8192
29
+ ROTATE_BYTES = 4 * 1024 * 1024
30
+ DEFAULT_MAX_SESSION_BYTES = 50 * 1024 * 1024
31
+ CAPPED_MARKER = ".capped"
32
+ SPOOL_SUFFIX = ".jsonl"
33
+
34
+ _O_FLAGS = (
35
+ os.O_WRONLY
36
+ | os.O_APPEND
37
+ | os.O_CREAT
38
+ | getattr(os, "O_BINARY", 0)
39
+ | getattr(os, "O_NOFOLLOW", 0)
40
+ | getattr(os, "O_CLOEXEC", 0)
41
+ | getattr(os, "O_NONBLOCK", 0) # a FIFO planted at the path fails (ENXIO) instead of hanging
42
+ )
43
+
44
+
45
+ def encode_line(event_json: str) -> bytes:
46
+ """Return the on-disk bytes for one event. Raises ``ValueError`` if over the line cap."""
47
+ body = event_json.encode("utf-8")
48
+ line = b"%08x %s\n" % (zlib.crc32(body) & 0xFFFFFFFF, body)
49
+ if len(line) > MAX_LINE_BYTES:
50
+ msg = "spool line exceeds the 8 KiB cap"
51
+ raise ValueError(msg)
52
+ return line
53
+
54
+
55
+ def decode_line(line: bytes) -> str | None:
56
+ """Return the event JSON text of one complete line (without ``\\n``), or ``None`` if bad.
57
+
58
+ "Bad" = wrong shape, non-hex CRC, CRC mismatch or non-UTF-8 payload.
59
+ """
60
+ if len(line) < 10 or line[8:9] != b" ":
61
+ return None
62
+ try:
63
+ expected = int(line[:8], 16)
64
+ except ValueError:
65
+ return None
66
+ body = line[9:]
67
+ if (zlib.crc32(body) & 0xFFFFFFFF) != expected:
68
+ return None
69
+ try:
70
+ return body.decode("utf-8")
71
+ except UnicodeDecodeError:
72
+ return None
73
+
74
+
75
+ def session_dir(paths: RuntimePaths, session_id: str) -> str:
76
+ return os.path.join(paths.spool, fs_name_for(session_id))
77
+
78
+
79
+ def writer_file(paths: RuntimePaths, session_id: str, writer: str) -> str:
80
+ return os.path.join(session_dir(paths, session_id), fs_name_for(writer) + SPOOL_SUFFIX)
81
+
82
+
83
+ def session_bytes(directory: str) -> int:
84
+ """Total size of the ``.jsonl`` files in a session directory (0 if missing)."""
85
+ total = 0
86
+ try:
87
+ with os.scandir(directory) as entries:
88
+ for entry in entries:
89
+ if entry.name.endswith(SPOOL_SUFFIX):
90
+ try:
91
+ total += entry.stat(follow_symlinks=False).st_size
92
+ except OSError:
93
+ continue
94
+ except OSError:
95
+ return 0
96
+ return total
97
+
98
+
99
+ def _open_append(path: str) -> int:
100
+ return os.open(path, _O_FLAGS, FILE_MODE)
101
+
102
+
103
+ def _cap_reached(directory: str, max_session_bytes: int) -> bool:
104
+ if session_bytes(directory) < max_session_bytes:
105
+ return False
106
+ try:
107
+ write_private_file(os.path.join(directory, CAPPED_MARKER), b"", exclusive=True)
108
+ except OSError:
109
+ pass
110
+ return True
111
+
112
+
113
+ def append_event( # noqa: PLR0911, PLR0912
114
+ paths: RuntimePaths,
115
+ session_id: str,
116
+ writer: str,
117
+ event_json: str,
118
+ *,
119
+ now_ms: int,
120
+ rotate_bytes: int = ROTATE_BYTES,
121
+ max_session_bytes: int = DEFAULT_MAX_SESSION_BYTES,
122
+ ) -> bool:
123
+ """Append one event line. Returns False (never raises for I/O) if nothing was written.
124
+
125
+ Concurrency: the write is a single ``os.write`` on an ``O_APPEND`` descriptor, so
126
+ parallel hook processes sharing a writer file do not interleave within a line on
127
+ POSIX local filesystems (PROVISIONAL, ADR 0001 Q5). Readers tolerate tears anyway.
128
+ """
129
+ try:
130
+ data = encode_line(event_json)
131
+ except ValueError:
132
+ return False
133
+ directory = session_dir(paths, session_id)
134
+ path = os.path.join(directory, fs_name_for(writer) + SPOOL_SUFFIX)
135
+ try:
136
+ if untrusted_reason(paths.root) is not None:
137
+ return False # never write into a directory another user could have prepared
138
+ if os.path.lexists(os.path.join(directory, CAPPED_MARKER)):
139
+ return False
140
+ try:
141
+ fd = _open_append(path)
142
+ except FileNotFoundError:
143
+ ensure_runtime_root(paths)
144
+ mkdir_private(directory)
145
+ fd = _open_append(path)
146
+ try:
147
+ size = os.fstat(fd).st_size
148
+ if size == 0 and os.name == "posix":
149
+ fchmod = getattr(os, "fchmod", None)
150
+ if fchmod is not None:
151
+ fchmod(fd, FILE_MODE) # explicit, not umask-dependent
152
+ if size == 0 or size >= rotate_bytes:
153
+ if size >= rotate_bytes:
154
+ os.close(fd)
155
+ fd = -1
156
+ _rotate(path, now_ms)
157
+ fd = _open_append(path)
158
+ if os.name == "posix":
159
+ fchmod = getattr(os, "fchmod", None)
160
+ if fchmod is not None:
161
+ fchmod(fd, FILE_MODE)
162
+ if _cap_reached(directory, max_session_bytes):
163
+ return False
164
+ written = os.write(fd, data)
165
+ if written != len(data): # torn by ENOSPC/EINTR: terminate the fragment
166
+ os.write(fd, b"\n")
167
+ return False
168
+ return True
169
+ finally:
170
+ if fd >= 0:
171
+ os.close(fd)
172
+ except OSError:
173
+ return False
174
+
175
+
176
+ def _rotate(path: str, now_ms: int) -> None:
177
+ base = path[: -len(SPOOL_SUFFIX)]
178
+ target = f"{base}.{now_ms}-{os.getpid()}"
179
+ suffix = 0
180
+ while os.path.lexists(target + SPOOL_SUFFIX) and suffix < 1000: # never clobber a rotation
181
+ suffix += 1
182
+ target = f"{base}.{now_ms}-{os.getpid()}-{suffix}"
183
+ try:
184
+ os.rename(path, target + SPOOL_SUFFIX)
185
+ except OSError:
186
+ pass # another process rotated it first; reopening creates/uses the new file
@@ -0,0 +1,242 @@
1
+ """Corruption-tolerant spool reader. Reports counts; never raises on bad data.
2
+
3
+ Tolerated and counted: missing/garbled CRC prefix, CRC mismatch, non-UTF-8 or non-JSON
4
+ payloads, over-long lines, events failing the Pydantic model, unknown ``schema_version``,
5
+ and a torn tail (final line without a newline). A torn fragment followed directly by a
6
+ good record (a killed writer, then a normal append) is resynchronised so the good
7
+ record is not lost.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import zlib
16
+ from dataclasses import dataclass, field
17
+
18
+ from pydantic import ValidationError
19
+
20
+ from cursorfleet.events.ids import fs_name_for
21
+ from cursorfleet.events.kinds import SCHEMA_VERSION
22
+ from cursorfleet.events.models import Event
23
+ from cursorfleet.state.runtime import RuntimePaths, untrusted_reason
24
+ from cursorfleet.state.spool import MAX_LINE_BYTES, SPOOL_SUFFIX, decode_line
25
+
26
+ MAX_READ_BYTES = 32 * 1024 * 1024
27
+ _RESYNC = re.compile(rb'[0-9a-f]{8} \{"schema_version"')
28
+
29
+
30
+ @dataclass
31
+ class Corruption:
32
+ """Counters for everything the reader skipped."""
33
+
34
+ bad_format: int = 0
35
+ bad_crc: int = 0
36
+ bad_json: int = 0
37
+ invalid_event: int = 0
38
+ unknown_version: int = 0
39
+ oversize: int = 0
40
+ misplaced: int = 0 # event whose session id does not match its spool directory
41
+ torn_tail: int = 0
42
+ resynced: int = 0 # good records recovered after a torn/garbled prefix (informational)
43
+
44
+ @property
45
+ def total(self) -> int:
46
+ return (
47
+ self.bad_format
48
+ + self.bad_crc
49
+ + self.bad_json
50
+ + self.invalid_event
51
+ + self.unknown_version
52
+ + self.oversize
53
+ + self.misplaced
54
+ + self.torn_tail
55
+ )
56
+
57
+ def add(self, other: Corruption) -> None:
58
+ for name in self.__dataclass_fields__:
59
+ setattr(self, name, getattr(self, name) + getattr(other, name))
60
+
61
+ def to_dict(self) -> dict[str, int]:
62
+ data = {name: getattr(self, name) for name in self.__dataclass_fields__}
63
+ data["total"] = self.total
64
+ return data
65
+
66
+ @classmethod
67
+ def from_dict(cls, data: dict[str, int]) -> Corruption:
68
+ return cls(**{n: int(data.get(n, 0)) for n in cls.__dataclass_fields__})
69
+
70
+
71
+ @dataclass
72
+ class FileRead:
73
+ events: list[Event] = field(default_factory=list)
74
+ new_offset: int = 0
75
+ size: int = 0
76
+ fingerprint: str | None = None
77
+ torn_tail_bytes: int = 0
78
+ corruption: Corruption = field(default_factory=Corruption)
79
+
80
+
81
+ def fingerprint_of(first_line: bytes) -> str:
82
+ return f"{zlib.crc32(first_line) & 0xFFFFFFFF:08x}"
83
+
84
+
85
+ def _parse_event(text: str, counts: Corruption) -> Event | None:
86
+ try:
87
+ obj = json.loads(text)
88
+ except (ValueError, RecursionError):
89
+ counts.bad_json += 1
90
+ return None
91
+ if not isinstance(obj, dict):
92
+ counts.bad_json += 1
93
+ return None
94
+ if obj.get("schema_version") != SCHEMA_VERSION:
95
+ counts.unknown_version += 1
96
+ return None
97
+ try:
98
+ return Event.model_validate(obj)
99
+ except ValidationError:
100
+ counts.invalid_event += 1
101
+ return None
102
+
103
+
104
+ def _decode_with_resync(line: bytes, counts: Corruption) -> str | None:
105
+ text = decode_line(line)
106
+ if text is not None:
107
+ return text
108
+ for match in _RESYNC.finditer(line, 1):
109
+ text = decode_line(line[match.start() :])
110
+ if text is not None:
111
+ counts.bad_crc += 1 # the torn/garbled prefix
112
+ counts.resynced += 1
113
+ return text
114
+ if len(line) < 10 or line[8:9] != b" ":
115
+ counts.bad_format += 1
116
+ else:
117
+ counts.bad_crc += 1
118
+ return None
119
+
120
+
121
+ def peek_fingerprint(path: str | os.PathLike[str]) -> str | None:
122
+ """Fingerprint of the first complete line (stable across rotation renames), or None."""
123
+ try:
124
+ with open(path, "rb") as handle:
125
+ head = handle.read(MAX_LINE_BYTES)
126
+ except OSError:
127
+ return None
128
+ newline = head.find(b"\n")
129
+ return fingerprint_of(head[:newline]) if newline >= 0 else None
130
+
131
+
132
+ def read_file(path: str | os.PathLike[str], offset: int = 0, *, final: bool = False) -> FileRead:
133
+ """Read complete lines from ``offset``. Never raises on content or missing files.
134
+
135
+ ``new_offset`` stops before an unterminated tail, so an incremental reader retries it
136
+ once the writer finishes. With ``final=True`` (one-shot replay/export) the tail is
137
+ also counted as ``torn_tail``.
138
+ """
139
+ result = FileRead(new_offset=offset)
140
+ try:
141
+ with open(path, "rb") as handle:
142
+ result.size = os.fstat(handle.fileno()).st_size
143
+ head = handle.read(MAX_LINE_BYTES)
144
+ newline = head.find(b"\n")
145
+ if newline >= 0:
146
+ result.fingerprint = fingerprint_of(head[:newline])
147
+ if offset > result.size:
148
+ offset = 0 # truncated or replaced file: start over (dedupe handles repeats)
149
+ result.new_offset = 0
150
+ handle.seek(offset)
151
+ data = handle.read(MAX_READ_BYTES)
152
+ except OSError:
153
+ return result
154
+ consumed = 0
155
+ while True:
156
+ end = data.find(b"\n", consumed)
157
+ if end < 0:
158
+ break
159
+ line = data[consumed:end]
160
+ consumed = end + 1
161
+ if not line.strip():
162
+ continue
163
+ if len(line) > MAX_LINE_BYTES:
164
+ result.corruption.oversize += 1
165
+ continue
166
+ text = _decode_with_resync(line, result.corruption)
167
+ if text is None:
168
+ continue
169
+ event = _parse_event(text, result.corruption)
170
+ if event is not None:
171
+ result.events.append(event)
172
+ tail = data[consumed:]
173
+ if consumed == 0 and len(data) >= MAX_READ_BYTES:
174
+ # one unterminated run longer than the whole read window: skip it so we make progress
175
+ result.corruption.oversize += 1
176
+ consumed = len(data)
177
+ tail = b""
178
+ result.new_offset = offset + consumed
179
+ result.torn_tail_bytes = len(tail)
180
+ if tail.strip() and final:
181
+ result.corruption.torn_tail += 1
182
+ return result
183
+
184
+
185
+ def list_spool_files(paths: RuntimePaths, session_id: str | None = None) -> list[str]:
186
+ """All ``*.jsonl`` files under ``spool/`` (or one session), sorted for determinism."""
187
+ root = paths.spool
188
+ found: list[str] = []
189
+ if untrusted_reason(paths.root) is not None:
190
+ return found # spool files in a directory others could write are not trusted input
191
+ try:
192
+ if session_id is not None:
193
+ directories = [os.path.join(root, fs_name_for(session_id))]
194
+ else:
195
+ with os.scandir(root) as entries:
196
+ directories = sorted(e.path for e in entries if e.is_dir(follow_symlinks=False))
197
+ except OSError:
198
+ return []
199
+ for directory in directories:
200
+ try:
201
+ with os.scandir(directory) as entries:
202
+ found.extend(
203
+ sorted(
204
+ e.path
205
+ for e in entries
206
+ if e.name.endswith(SPOOL_SUFFIX) and e.is_file(follow_symlinks=False)
207
+ )
208
+ )
209
+ except OSError:
210
+ continue
211
+ return found
212
+
213
+
214
+ def accept_placement(path: str, event: Event, counts: Corruption) -> bool:
215
+ """An event is only trusted inside the spool directory named for its own session."""
216
+ if os.path.basename(os.path.dirname(path)) == fs_name_for(event.session_id):
217
+ return True
218
+ counts.misplaced += 1
219
+ return False
220
+
221
+
222
+ def read_all(
223
+ paths: RuntimePaths, session_id: str | None = None
224
+ ) -> tuple[list[Event], Corruption, int]:
225
+ """Read every spool file once. Returns (events, corruption, files_read).
226
+
227
+ With ``session_id`` only that session's directory is read and events are filtered to
228
+ the id, so a forged file cannot smuggle another session in.
229
+ """
230
+ events: list[Event] = []
231
+ corruption = Corruption()
232
+ files = list_spool_files(paths, session_id)
233
+ for file in files:
234
+ result = read_file(file, 0, final=True)
235
+ corruption.add(result.corruption)
236
+ events.extend(
237
+ e
238
+ for e in result.events
239
+ if (session_id is None or e.session_id == session_id)
240
+ and accept_placement(file, e, corruption)
241
+ )
242
+ return events, corruption, len(files)