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,33 @@
1
+ """Optional ``watchfiles`` integration: refresh promptly when the spool changes.
2
+
3
+ Polling is always on (the default 2 s interval); watching only makes the UI react sooner and
4
+ is skipped when ``watchfiles`` is not installed (``pip install cursorfleet[watch]``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import importlib.util
11
+ from collections.abc import AsyncIterator, Callable
12
+ from typing import Any, cast
13
+
14
+ WatchFactory = Callable[[str], AsyncIterator[object]]
15
+
16
+
17
+ def watchfiles_available() -> bool:
18
+ return importlib.util.find_spec("watchfiles") is not None
19
+
20
+
21
+ def default_watch_factory() -> WatchFactory | None:
22
+ """A factory yielding one item per batch of changes under a directory, or ``None``."""
23
+ if not watchfiles_available():
24
+ return None
25
+ awatch: Any = importlib.import_module("watchfiles").awatch
26
+
27
+ def factory(path: str) -> AsyncIterator[object]:
28
+ return cast(
29
+ AsyncIterator[object],
30
+ awatch(path, debounce=300, step=100, yield_on_timeout=False, raise_interrupt=False),
31
+ )
32
+
33
+ return factory
File without changes
@@ -0,0 +1,148 @@
1
+ """Validation of agent-declared Markdown artifacts under ``.cursorfleet/work/`` (ADR 0006).
2
+
3
+ Artifacts are self-reported and untrusted: this module only checks *shape*. The
4
+ body is never parsed or copied anywhere. PROVISIONAL (ADR 0006): the ``schema``
5
+ value and field set may change once agents have been observed using them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import PurePosixPath
14
+
15
+ from cursorfleet.events.ids import SAFE_ID_PATTERN
16
+ from cursorfleet.events.pathcheck import check_relative_posix
17
+ from cursorfleet.workflow.frontmatter import Document, FrontmatterError, parse_frontmatter
18
+
19
+ ARTIFACT_SCHEMA_LEGACY = "cursorfleet.artifact/1"
20
+ ARTIFACT_SCHEMA = "cursorfleet.artifact/0.1"
21
+ ARTIFACT_SCHEMAS: frozenset[str] = frozenset({ARTIFACT_SCHEMA, ARTIFACT_SCHEMA_LEGACY})
22
+ ARTIFACT_KINDS: frozenset[str] = frozenset(
23
+ {"plan.created", "handoff.created", "blocker.raised", "context.loaded"}
24
+ )
25
+ REQUIRED_KEYS_LEGACY: frozenset[str] = frozenset(
26
+ {"schema", "kind", "task", "author_role", "created"}
27
+ )
28
+ REQUIRED_KEYS: frozenset[str] = frozenset(
29
+ {"schema", "kind", "task", "artifact_id", "revision", "author_role", "created"}
30
+ )
31
+ OPTIONAL_KEYS: frozenset[str] = frozenset({"to_role", "issue_ref", "context_refs", "digest"})
32
+ MAX_ARTIFACT_BYTES = 64 * 1024
33
+ MAX_CONTEXT_REFS = 50
34
+ MAX_ISSUE_REF_CHARS = 128
35
+
36
+ TASK_SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
37
+ _SAFE_ID = re.compile(SAFE_ID_PATTERN)
38
+ _URL_USERINFO = re.compile(r"://[^/\s]*@")
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ArtifactProblem:
43
+ code: str
44
+ message: str
45
+
46
+
47
+ def _check_created(value: object) -> str | None:
48
+ if not isinstance(value, str):
49
+ return "must be a string"
50
+ try:
51
+ datetime.fromisoformat(value)
52
+ except ValueError:
53
+ return "must be an ISO 8601 date or timestamp"
54
+ return None
55
+
56
+
57
+ def validate_artifact_text(text: str, rel_path: str) -> list[ArtifactProblem]: # noqa: PLR0912, PLR0915
58
+ """Validate one artifact. ``rel_path`` is relative to the work dir (``<task>/<file>.md``)."""
59
+ problems: list[ArtifactProblem] = []
60
+ if len(text.encode("utf-8")) > MAX_ARTIFACT_BYTES:
61
+ problems.append(ArtifactProblem("artifact.too_large", f"larger than {MAX_ARTIFACT_BYTES}"))
62
+ return problems
63
+ try:
64
+ doc: Document = parse_frontmatter(text)
65
+ except FrontmatterError as exc:
66
+ return [ArtifactProblem("artifact.frontmatter", str(exc))]
67
+ meta = doc.meta
68
+ schema = meta.get("schema") if isinstance(meta.get("schema"), str) else None
69
+ required = REQUIRED_KEYS if schema == ARTIFACT_SCHEMA else REQUIRED_KEYS_LEGACY
70
+ allowed_keys = required | OPTIONAL_KEYS
71
+
72
+ missing = sorted(required - meta.keys())
73
+ if missing:
74
+ problems.append(ArtifactProblem("artifact.missing_key", f"missing: {', '.join(missing)}"))
75
+ unknown = sorted(meta.keys() - allowed_keys)
76
+ if unknown:
77
+ problems.append(ArtifactProblem("artifact.unknown_key", f"unknown: {', '.join(unknown)}"))
78
+
79
+ if schema is not None and schema not in ARTIFACT_SCHEMAS:
80
+ problems.append(
81
+ ArtifactProblem("artifact.schema", f"schema must be one of {sorted(ARTIFACT_SCHEMAS)}")
82
+ )
83
+ if "kind" in meta and meta["kind"] not in ARTIFACT_KINDS:
84
+ allowed = ", ".join(sorted(ARTIFACT_KINDS))
85
+ problems.append(ArtifactProblem("artifact.kind", f"kind must be one of: {allowed}"))
86
+
87
+ parts = PurePosixPath(rel_path).parts
88
+ dir_task = parts[0] if len(parts) >= 2 else None
89
+ if dir_task is None:
90
+ problems.append(
91
+ ArtifactProblem("artifact.location", "must live in <work>/<task>/<file>.md")
92
+ )
93
+ task = meta.get("task")
94
+ if "task" in meta:
95
+ if not isinstance(task, str) or not TASK_SLUG.match(task):
96
+ problems.append(ArtifactProblem("artifact.task", "task must be a lowercase slug"))
97
+ elif dir_task is not None and task != dir_task:
98
+ problems.append(ArtifactProblem("artifact.task", "task must equal the directory name"))
99
+
100
+ for key in ("author_role", "to_role"):
101
+ if key in meta:
102
+ value = meta[key]
103
+ if not isinstance(value, str) or not _SAFE_ID.match(value):
104
+ problems.append(ArtifactProblem(f"artifact.{key}", f"{key} must be a safe id"))
105
+
106
+ if "created" in meta:
107
+ err = _check_created(meta["created"])
108
+ if err:
109
+ problems.append(ArtifactProblem("artifact.created", f"created {err}"))
110
+
111
+ if "issue_ref" in meta:
112
+ ref = meta["issue_ref"]
113
+ if (
114
+ not isinstance(ref, str)
115
+ or not ref
116
+ or len(ref) > MAX_ISSUE_REF_CHARS
117
+ or not ref.isprintable()
118
+ or _URL_USERINFO.search(ref)
119
+ ):
120
+ problems.append(
121
+ ArtifactProblem(
122
+ "artifact.issue_ref",
123
+ "issue_ref must be short printable text without URL credentials",
124
+ )
125
+ )
126
+
127
+ if "context_refs" in meta:
128
+ refs = meta["context_refs"]
129
+ if not isinstance(refs, list) or len(refs) > MAX_CONTEXT_REFS:
130
+ problems.append(
131
+ ArtifactProblem(
132
+ "artifact.context_refs",
133
+ f"context_refs must be a list of at most {MAX_CONTEXT_REFS} paths",
134
+ )
135
+ )
136
+ else:
137
+ for ref_item in refs:
138
+ try:
139
+ if not isinstance(ref_item, str):
140
+ msg = "not a string"
141
+ raise ValueError(msg)
142
+ check_relative_posix(ref_item)
143
+ except ValueError as exc:
144
+ problems.append(
145
+ ArtifactProblem("artifact.context_refs", f"bad context_ref: {exc}")
146
+ )
147
+ break
148
+ return problems
@@ -0,0 +1,232 @@
1
+ """Strict frontmatter parser: a flat YAML subset, stdlib only (ADR 0006).
2
+
3
+ Accepted: ``key: value`` lines where the value is a plain scalar, a
4
+ double-quoted JSON-style string or a single-quoted string, ``true``/``false``,
5
+ an integer, or a list of such scalars written as ``- item`` lines or an inline
6
+ ``[a, b]``. Rejected: nesting, anchors, tags, multi-line scalars, duplicate keys,
7
+ comments after values, and anything over the size caps. No YAML library is used,
8
+ so none of YAML's unsafe features exist here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import tomllib
16
+ from dataclasses import dataclass
17
+
18
+ MAX_FRONTMATTER_BYTES = 16 * 1024
19
+ MAX_FRONTMATTER_LINES = 200
20
+ MAX_LIST_ITEMS = 100
21
+
22
+ Scalar = str | int | bool
23
+ Value = Scalar | list[Scalar]
24
+
25
+ _KEY = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
26
+ _INT = re.compile(r"^-?[0-9]{1,15}$")
27
+ _FENCE = "---"
28
+ _TOML_FENCE = "+++"
29
+ _PLAIN_FORBIDDEN_START = set("&*!|>%@`{}[]#,'\"")
30
+
31
+
32
+ class FrontmatterError(ValueError):
33
+ """Frontmatter is missing, malformed or outside the accepted subset."""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Document:
38
+ meta: dict[str, Value]
39
+ body: str
40
+
41
+
42
+ def _scalar(raw: str) -> Scalar:
43
+ raw = raw.strip()
44
+ if raw == "":
45
+ msg = "empty value"
46
+ raise FrontmatterError(msg)
47
+ if raw[0] == '"':
48
+ try:
49
+ value = json.loads(raw)
50
+ except json.JSONDecodeError as exc:
51
+ msg = f"invalid double-quoted string: {raw[:40]!r}"
52
+ raise FrontmatterError(msg) from exc
53
+ if not isinstance(value, str):
54
+ msg = "invalid double-quoted string"
55
+ raise FrontmatterError(msg)
56
+ return value
57
+ if raw[0] == "'":
58
+ if len(raw) < 2 or raw[-1] != "'" or "'" in raw[1:-1].replace("''", ""):
59
+ msg = f"invalid single-quoted string: {raw[:40]!r}"
60
+ raise FrontmatterError(msg)
61
+ return raw[1:-1].replace("''", "'")
62
+ if raw[0] in _PLAIN_FORBIDDEN_START:
63
+ msg = f"unsupported YAML construct starting with {raw[0]!r}"
64
+ raise FrontmatterError(msg)
65
+ if " #" in raw:
66
+ msg = "inline comments are not supported"
67
+ raise FrontmatterError(msg)
68
+ if raw == "true":
69
+ return True
70
+ if raw == "false":
71
+ return False
72
+ if _INT.match(raw):
73
+ return int(raw)
74
+ if raw.startswith(("- ", "? ")) or raw.endswith(":") or ": " in raw:
75
+ msg = f"ambiguous plain scalar {raw[:40]!r} (quote it)"
76
+ raise FrontmatterError(msg)
77
+ return raw
78
+
79
+
80
+ def _inline_list(raw: str) -> list[Scalar]:
81
+ inner = raw.strip()[1:-1].strip()
82
+ if inner == "":
83
+ return []
84
+ # Split on commas outside double quotes.
85
+ items: list[str] = []
86
+ buf: list[str] = []
87
+ in_quote = False
88
+ escaped = False
89
+ for ch in inner:
90
+ if in_quote:
91
+ buf.append(ch)
92
+ if escaped:
93
+ escaped = False
94
+ elif ch == "\\":
95
+ escaped = True
96
+ elif ch == '"':
97
+ in_quote = False
98
+ elif ch == '"':
99
+ in_quote = True
100
+ buf.append(ch)
101
+ elif ch == ",":
102
+ items.append("".join(buf))
103
+ buf = []
104
+ else:
105
+ buf.append(ch)
106
+ items.append("".join(buf))
107
+ return [_scalar(item) for item in items]
108
+
109
+
110
+ def parse_frontmatter(text: str) -> Document:
111
+ """Parse YAML ``---`` frontmatter (legacy artifacts and Cursor kit files)."""
112
+ if text.startswith(_TOML_FENCE):
113
+ return parse_toml_frontmatter(text)
114
+ return _parse_yaml_frontmatter(text)
115
+
116
+
117
+ def parse_toml_frontmatter(text: str) -> Document: # noqa: PLR0912
118
+ """Parse TOML ``+++`` frontmatter (cursorfleet.artifact/0.1)."""
119
+ if not text.startswith(_TOML_FENCE):
120
+ msg = "TOML frontmatter must start with +++"
121
+ raise FrontmatterError(msg)
122
+ if len(text.encode("utf-8")) > MAX_FRONTMATTER_BYTES + 64 * 1024:
123
+ msg = "artifact too large"
124
+ raise FrontmatterError(msg)
125
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
126
+ close = None
127
+ for index, line in enumerate(lines[1:], start=1):
128
+ if line.strip() == _TOML_FENCE:
129
+ close = index
130
+ break
131
+ if close is None:
132
+ msg = "unclosed TOML frontmatter"
133
+ raise FrontmatterError(msg)
134
+ block = "\n".join(lines[1:close])
135
+ try:
136
+ meta_raw = tomllib.loads(block)
137
+ except tomllib.TOMLDecodeError as exc:
138
+ msg = f"invalid TOML frontmatter: {exc}"
139
+ raise FrontmatterError(msg) from exc
140
+ if not isinstance(meta_raw, dict):
141
+ msg = "TOML frontmatter must be a table"
142
+ raise FrontmatterError(msg)
143
+ meta: dict[str, Value] = {}
144
+ for key, value in meta_raw.items():
145
+ if not _KEY.match(key):
146
+ msg = f"invalid key {key!r}"
147
+ raise FrontmatterError(msg)
148
+ if isinstance(value, bool | int | str):
149
+ meta[key] = value
150
+ elif isinstance(value, list):
151
+ if len(value) > MAX_LIST_ITEMS:
152
+ msg = "too many list items"
153
+ raise FrontmatterError(msg)
154
+ meta[key] = [item for item in value if isinstance(item, bool | int | str)]
155
+ else:
156
+ msg = f"unsupported TOML value for {key!r}"
157
+ raise FrontmatterError(msg)
158
+ return Document(meta=meta, body="\n".join(lines[close + 1 :]))
159
+
160
+
161
+ def _parse_yaml_frontmatter(text: str) -> Document: # noqa: PLR0912, PLR0915
162
+ """Split ``text`` into frontmatter and body. Raises ``FrontmatterError``."""
163
+ text = text.replace("\r\n", "\n")
164
+ if text.startswith("\ufeff"):
165
+ text = text[1:]
166
+ lines = text.split("\n")
167
+ if not lines or lines[0].rstrip() != _FENCE:
168
+ msg = "file must start with a '---' frontmatter fence"
169
+ raise FrontmatterError(msg)
170
+ try:
171
+ close = next(i for i in range(1, len(lines)) if lines[i].rstrip() == _FENCE)
172
+ except StopIteration:
173
+ msg = "frontmatter is not closed with '---'"
174
+ raise FrontmatterError(msg) from None
175
+ raw_lines = lines[1:close]
176
+ if close > MAX_FRONTMATTER_LINES or sum(len(x) + 1 for x in raw_lines) > MAX_FRONTMATTER_BYTES:
177
+ msg = "frontmatter is too large"
178
+ raise FrontmatterError(msg)
179
+
180
+ meta: dict[str, Value] = {}
181
+ current_list: str | None = None
182
+ for number, line in enumerate(raw_lines, start=2):
183
+ if line.strip() == "":
184
+ current_list = None
185
+ continue
186
+ if line.lstrip().startswith("#"):
187
+ continue
188
+ if line.startswith((" - ", "- ", " - ")) or line.strip() == "-":
189
+ if current_list is None:
190
+ msg = f"line {number}: list item without a key"
191
+ raise FrontmatterError(msg)
192
+ target = meta[current_list]
193
+ if not isinstance(target, list):
194
+ msg = f"line {number}: unexpected list item"
195
+ raise FrontmatterError(msg)
196
+ if len(target) >= MAX_LIST_ITEMS:
197
+ msg = f"line {number}: too many list items"
198
+ raise FrontmatterError(msg)
199
+ target.append(_scalar(line.strip()[1:]))
200
+ continue
201
+ if line[0] in " \t":
202
+ msg = f"line {number}: nested or indented values are not supported"
203
+ raise FrontmatterError(msg)
204
+ key, sep, rest = line.partition(":")
205
+ if not sep or not _KEY.match(key):
206
+ msg = f"line {number}: expected 'key: value'"
207
+ raise FrontmatterError(msg)
208
+ if key in meta:
209
+ msg = f"line {number}: duplicate key {key!r}"
210
+ raise FrontmatterError(msg)
211
+ current_list = None
212
+ rest = rest.strip()
213
+ if rest == "":
214
+ meta[key] = []
215
+ current_list = key
216
+ elif rest.startswith("["):
217
+ if not rest.endswith("]"):
218
+ msg = f"line {number}: unterminated inline list"
219
+ raise FrontmatterError(msg)
220
+ meta[key] = _inline_list(rest)
221
+ else:
222
+ try:
223
+ meta[key] = _scalar(rest)
224
+ except FrontmatterError as exc:
225
+ msg = f"line {number}: {exc}"
226
+ raise FrontmatterError(msg) from exc
227
+ return Document(meta=meta, body="\n".join(lines[close + 1 :]))
228
+
229
+
230
+ def yaml_string(value: str) -> str:
231
+ """Render ``value`` as a double-quoted scalar this parser (and YAML) can read back."""
232
+ return json.dumps(value, ensure_ascii=False)
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.5
2
+ Name: cursorfleet
3
+ Version: 0.1.0
4
+ Summary: Unofficial local TUI and workflow harness (observe-only) for Cursor agent teams.
5
+ Project-URL: Homepage, https://github.com/M9nx/cursorfleet
6
+ Project-URL: Documentation, https://github.com/M9nx/cursorfleet/tree/main/docs
7
+ Project-URL: Source, https://github.com/M9nx/cursorfleet
8
+ Project-URL: Issues, https://github.com/M9nx/cursorfleet/issues
9
+ Project-URL: Changelog, https://github.com/M9nx/cursorfleet/blob/main/CHANGELOG.md
10
+ Project-URL: Security, https://github.com/M9nx/cursorfleet/blob/main/SECURITY.md
11
+ Author: M9nx
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: agents,cursor,git-worktree,hooks,observability,tui,workflow
15
+ Classifier: Development Status :: 2 - Pre-Alpha
16
+ Classifier: Environment :: Console
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Operating System :: Microsoft :: Windows
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
27
+ Classifier: Topic :: Software Development
28
+ Classifier: Topic :: Software Development :: Version Control :: Git
29
+ Classifier: Topic :: System :: Monitoring
30
+ Classifier: Typing :: Typed
31
+ Requires-Python: >=3.11
32
+ Requires-Dist: pydantic>=2
33
+ Requires-Dist: textual>=0.80
34
+ Requires-Dist: typer>=0.12
35
+ Provides-Extra: watch
36
+ Requires-Dist: watchfiles>=0.21; extra == 'watch'
37
+ Description-Content-Type: text/markdown
38
+
39
+ <div align="center">
40
+
41
+ <a href="https://m9nx.github.io/cursorfleet/">
42
+ <img
43
+ src="public-docs/assets/og-card.png"
44
+ alt="CursorFleet — local observe-only harness for Cursor agent teams"
45
+ width="640"
46
+ />
47
+ </a>
48
+
49
+ <p>
50
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
51
+ <img src="https://img.shields.io/badge/status-pre--alpha-orange.svg" alt="Status: pre-alpha" />
52
+ <img src="https://img.shields.io/badge/python-3.11%2B-blue.svg" alt="Python 3.11+" />
53
+ <a href="https://m9nx.github.io/cursorfleet/"><img src="https://img.shields.io/badge/docs-GitHub_Pages-222?style=flat-square&logo=github" alt="Documentation site" /></a>
54
+ </p>
55
+
56
+ <p>
57
+ <a href="https://m9nx.github.io/cursorfleet/">Website</a>
58
+ ·
59
+ <a href="docs/quickstart.md">Quickstart</a>
60
+ ·
61
+ <a href="docs/demo.md">Demo</a>
62
+ ·
63
+ <a href="CONTRIBUTING.md">Contributing</a>
64
+ </p>
65
+
66
+ </div>
67
+
68
+ A local TUI and workflow harness for Cursor agent teams: see which local agents and worktrees
69
+ are doing what, from passive hooks and read-only git, without sending anything anywhere.
70
+
71
+ > **Unofficial:** CursorFleet is not affiliated with or endorsed by Anysphere or Cursor. "Cursor" is a trademark of its respective owner.
72
+
73
+ ## Status
74
+
75
+ **Pre-alpha, not released.** There is no PyPI package, tag or GitHub release yet.
76
+
77
+ - v0.1 is **Observe**: no enforcement, no forced approvals, and no cloud-agent visibility
78
+ (local Cursor sessions only). It is not a security boundary: a malicious or prompt-injected
79
+ agent can bypass or forge what it shows ([threat model](docs/threat-model.md)).
80
+ - **Live Cursor (product hooks):** maintainer smoke on **Linux Cursor IDE 3.22.7** confirmed hook
81
+ telemetry, subagent lifecycle, and artifact scanning; see
82
+ [live validation index](docs/evidence/live-validation-index.md). **Q1/Q2** (subagent identity
83
+ on tool hooks, custom `subagent_type` naming) remain **OPEN**; M0a formal matrix is not complete.
84
+ - **M2.5 active:** task-centric observer work (Active Run) is in progress; see
85
+ [status](docs/status.md). The M0a capture kit ([spike](spike/README.md)) remains separate.
86
+ - **Supported surface for claims today:** local Cursor IDE (desktop) on Linux for the exercised
87
+ smoke only; CLI, Agents Window, worktrees, and parallel subagents are not claimed until matrix PASS.
88
+ The Cursor CLI, the Agents Window, Cursor-managed and manual worktrees, parallel
89
+ subagents and cloud agents are not claimed.
90
+ - The name "CursorFleet" is a working name; the final name is a release gate
91
+ ([ADR 0005](docs/adr/0005-naming-and-trademark.md)).
92
+ - Linux is the tested platform; macOS and Windows are CI-tested only
93
+ ([platform support](docs/platform-support.md)).
94
+ - Cursor is the only officially supported IDE; internals are adapter-ready but no other
95
+ adapter is planned for v0.1.
96
+ - Local only: no network calls, no telemetry, no accounts (checked by a test). It never stores
97
+ prompts, model thinking, responses, file contents, command output, environment variables,
98
+ emails or transcript paths ([privacy](docs/privacy.md)).
99
+
100
+ ## Install
101
+
102
+ Needs Python 3.11+ and git. Until it is published, install from a checkout:
103
+
104
+ ```bash
105
+ git clone https://github.com/M9nx/cursorfleet && cd cursorfleet
106
+ uv tool install . # or: pipx install .
107
+ cursorfleet --version
108
+ ```
109
+
110
+ (Once released: `uv tool install cursorfleet` or `pipx install cursorfleet`; see the
111
+ [release checklist](docs/release-checklist.md).)
112
+
113
+ ## Quickstart
114
+
115
+ ```bash
116
+ # from the repository root (Git is required; no --allow-non-git)
117
+ cursorfleet init --cursor --dry-run # review the exact diff first
118
+ cursorfleet init --cursor # install the observe-only hooks and kit (once per repo)
119
+ # ...work with Cursor agents as usual...
120
+ cursorfleet doctor # check hooks, runtime dir and spool
121
+ cursorfleet tui # open the dashboard (needs an interactive terminal)
122
+ cursorfleet status --json # stable machine-readable snapshot for scripts
123
+ cursorfleet uninstall # remove exactly what init added
124
+ ```
125
+
126
+ Full walkthrough: [docs/quickstart.md](docs/quickstart.md). No Cursor handy? Run the
127
+ [synthetic demo](docs/demo.md): `scripts/demo.sh`.
128
+
129
+ Dashboard keys: `o` overview, `l` timeline, `w` worktrees, `g` gates, `e` evidence, `v`
130
+ violations (v0.2 placeholder), `/` filter, `p` pin, `t` tiles (160+ columns), `r` re-read,
131
+ `?` help, `q` quit. The TUI only observes: silence shows as STALE / OFFLINE, never idle, and
132
+ gate tiles are heuristic and non-authoritative, not proof that anything passed. Details:
133
+ [docs/tui.md](docs/tui.md).
134
+
135
+ ## Documentation
136
+
137
+ [Quickstart](docs/quickstart.md) · [governance model](docs/governance.md) ·
138
+ [demo](docs/demo.md) · [kit reference](docs/kit.md) · [TUI](docs/tui.md) ·
139
+ [`status --json`](docs/status-json.md) · [product contract](docs/product-contract.md) ·
140
+ [architecture](docs/architecture.md) · [privacy](docs/privacy.md) ·
141
+ [threat model](docs/threat-model.md) · [security review](docs/security-review-v0.1.md) ·
142
+ [platform support](docs/platform-support.md) · [hook latency](docs/hook-latency.md) ·
143
+ [release checklist](docs/release-checklist.md) · [project status](docs/status.md) ·
144
+ [empirical test plan](docs/empirical-test-plan.md) · [follow-ups](docs/follow-ups.md) ·
145
+ [docs-site plan](docs/docs-site-plan.md) · [ADRs](docs/adr/README.md) ·
146
+ [changelog](CHANGELOG.md)
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ uv sync --all-extras
152
+ uv run cursorfleet --version
153
+ uv run ruff check . && uv run ruff format --check . && uv run mypy src && uv run pytest
154
+ ```
155
+
156
+ Regenerate schemas with `uv run python scripts/gen_schemas.py`. See
157
+ [CONTRIBUTING.md](CONTRIBUTING.md), [AGENTS.md](AGENTS.md) and [SECURITY.md](SECURITY.md).
158
+
159
+ ## License
160
+
161
+ MIT, see [LICENSE](LICENSE).