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
cursorfleet/tui/watch.py
ADDED
|
@@ -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).
|