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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Read-only git invocation: argv lists, explicit timeouts, no fetch, no mutation.
|
|
2
|
+
|
|
3
|
+
Hardening for hostile repositories (threat model, "Malicious repository"):
|
|
4
|
+
|
|
5
|
+
- ``core.fsmonitor`` is forced off, because a repo-local fsmonitor hook would otherwise run
|
|
6
|
+
an arbitrary program when we call ``git status``.
|
|
7
|
+
- ``--no-optional-locks`` / ``GIT_OPTIONAL_LOCKS=0`` stop ``status`` from refreshing (writing)
|
|
8
|
+
the index.
|
|
9
|
+
- Inherited ``GIT_DIR``/``GIT_WORK_TREE``/``GIT_INDEX_FILE`` are dropped so a hook context
|
|
10
|
+
cannot redirect us, and prompts/pagers are disabled.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from cursorfleet.safeexe import find_executable
|
|
21
|
+
|
|
22
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
23
|
+
MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
_DROP_ENV = (
|
|
26
|
+
"GIT_DIR",
|
|
27
|
+
"GIT_WORK_TREE",
|
|
28
|
+
"GIT_INDEX_FILE",
|
|
29
|
+
"GIT_OBJECT_DIRECTORY",
|
|
30
|
+
"GIT_COMMON_DIR",
|
|
31
|
+
"GIT_NAMESPACE",
|
|
32
|
+
"GIT_EXTERNAL_DIFF",
|
|
33
|
+
"GIT_ASKPASS",
|
|
34
|
+
"GIT_SSH_COMMAND",
|
|
35
|
+
)
|
|
36
|
+
_BASE_ARGS = ("--no-optional-locks", "-c", "core.fsmonitor=false")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GitError(Exception):
|
|
40
|
+
"""Base class for collector failures."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GitUnavailable(GitError):
|
|
44
|
+
"""The ``git`` executable could not be run."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotAGitRepo(GitError):
|
|
48
|
+
"""The path is not inside a git repository."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class GitResult:
|
|
53
|
+
returncode: int
|
|
54
|
+
stdout: bytes
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ok(self) -> bool:
|
|
58
|
+
return self.returncode == 0
|
|
59
|
+
|
|
60
|
+
def text(self) -> str:
|
|
61
|
+
return self.stdout.decode("utf-8", "replace")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def git_env() -> dict[str, str]:
|
|
65
|
+
env = {k: v for k, v in os.environ.items() if k not in _DROP_ENV}
|
|
66
|
+
env.update(
|
|
67
|
+
{
|
|
68
|
+
"GIT_OPTIONAL_LOCKS": "0",
|
|
69
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
70
|
+
"GIT_PAGER": "cat",
|
|
71
|
+
"LC_ALL": "C",
|
|
72
|
+
"LANG": "C",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
return env
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_git(
|
|
79
|
+
args: Sequence[str], cwd: str, *, timeout: float = DEFAULT_TIMEOUT_S
|
|
80
|
+
) -> GitResult | None:
|
|
81
|
+
"""Run ``git <args>`` in ``cwd``. Returns ``None`` on timeout or OS-level failure.
|
|
82
|
+
|
|
83
|
+
Raises :class:`GitUnavailable` only when the executable itself is missing.
|
|
84
|
+
"""
|
|
85
|
+
exe = find_executable("git") # never the current directory (hostile repositories)
|
|
86
|
+
if exe is None:
|
|
87
|
+
msg = "git executable not found"
|
|
88
|
+
raise GitUnavailable(msg)
|
|
89
|
+
argv = [exe, *_BASE_ARGS, *args]
|
|
90
|
+
try:
|
|
91
|
+
completed = subprocess.run( # noqa: S603 - argv list, fixed executable, no shell
|
|
92
|
+
argv,
|
|
93
|
+
cwd=cwd,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
timeout=timeout,
|
|
96
|
+
check=False,
|
|
97
|
+
env=git_env(),
|
|
98
|
+
stdin=subprocess.DEVNULL,
|
|
99
|
+
)
|
|
100
|
+
except FileNotFoundError as exc:
|
|
101
|
+
if not os.path.isdir(cwd):
|
|
102
|
+
return None
|
|
103
|
+
msg = "git executable not found"
|
|
104
|
+
raise GitUnavailable(msg) from exc
|
|
105
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
106
|
+
return None
|
|
107
|
+
return GitResult(completed.returncode, completed.stdout[:MAX_OUTPUT_BYTES])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def git_common_dir(start: str, *, timeout: float = DEFAULT_TIMEOUT_S) -> str:
|
|
111
|
+
"""``git rev-parse --git-common-dir`` from ``start``, absolute and realpath-resolved.
|
|
112
|
+
|
|
113
|
+
The contract of ADR 0002; the hook hot path uses an equivalent file walk instead and
|
|
114
|
+
a contract test compares the two.
|
|
115
|
+
"""
|
|
116
|
+
start = os.path.realpath(start)
|
|
117
|
+
result = run_git(["rev-parse", "--git-common-dir"], start, timeout=timeout)
|
|
118
|
+
if result is None or not result.ok:
|
|
119
|
+
msg = f"not a git repository (or git failed): {start}"
|
|
120
|
+
raise NotAGitRepo(msg)
|
|
121
|
+
value = result.text().strip()
|
|
122
|
+
if not value:
|
|
123
|
+
msg = "git returned an empty common dir"
|
|
124
|
+
raise NotAGitRepo(msg)
|
|
125
|
+
return os.path.realpath(value if os.path.isabs(value) else os.path.join(start, value))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def git_toplevel(start: str, *, timeout: float = DEFAULT_TIMEOUT_S) -> str | None:
|
|
129
|
+
"""``git rev-parse --show-toplevel`` (realpath), or ``None`` for bare repos / non-repos."""
|
|
130
|
+
real = os.path.realpath(start)
|
|
131
|
+
result = run_git(["rev-parse", "--show-toplevel"], real, timeout=timeout)
|
|
132
|
+
if result is None or not result.ok:
|
|
133
|
+
return None
|
|
134
|
+
value = result.text().strip()
|
|
135
|
+
return os.path.realpath(value) if value else None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Parse ``git worktree list --porcelain`` output (NUL- or newline-separated)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
MAX_WORKTREES = 500
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class WorktreeEntry:
|
|
12
|
+
"""One record of the porcelain listing (git's own view; nothing is verified here)."""
|
|
13
|
+
|
|
14
|
+
path: str
|
|
15
|
+
head: str | None = None
|
|
16
|
+
branch: str | None = None # short name for refs/heads/*, else the ref without ``refs/``
|
|
17
|
+
detached: bool = False
|
|
18
|
+
bare: bool = False
|
|
19
|
+
locked: bool = False
|
|
20
|
+
prunable: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _branch_name(ref: str) -> str:
|
|
24
|
+
if ref.startswith("refs/heads/"):
|
|
25
|
+
return ref[len("refs/heads/") :]
|
|
26
|
+
return ref.removeprefix("refs/")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_porcelain(text: str, *, nul: bool) -> list[WorktreeEntry]:
|
|
30
|
+
"""Parse porcelain text. ``nul=True`` for ``-z`` output, else newline-separated."""
|
|
31
|
+
separator = "\0" if nul else "\n"
|
|
32
|
+
entries: list[WorktreeEntry] = []
|
|
33
|
+
current: dict[str, str | bool] = {}
|
|
34
|
+
|
|
35
|
+
def flush() -> None:
|
|
36
|
+
path = current.get("path")
|
|
37
|
+
if isinstance(path, str) and path:
|
|
38
|
+
head = current.get("head")
|
|
39
|
+
branch = current.get("branch")
|
|
40
|
+
entries.append(
|
|
41
|
+
WorktreeEntry(
|
|
42
|
+
path=path,
|
|
43
|
+
head=head if isinstance(head, str) and head else None,
|
|
44
|
+
branch=branch if isinstance(branch, str) and branch else None,
|
|
45
|
+
detached=current.get("detached") is True,
|
|
46
|
+
bare=current.get("bare") is True,
|
|
47
|
+
locked=current.get("locked") is True,
|
|
48
|
+
prunable=current.get("prunable") is True,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
current.clear()
|
|
52
|
+
|
|
53
|
+
for line in text.split(separator):
|
|
54
|
+
if not line:
|
|
55
|
+
flush()
|
|
56
|
+
continue
|
|
57
|
+
key, _, value = line.partition(" ")
|
|
58
|
+
if key == "worktree":
|
|
59
|
+
if current:
|
|
60
|
+
flush()
|
|
61
|
+
current["path"] = value
|
|
62
|
+
elif key == "HEAD":
|
|
63
|
+
current["head"] = value
|
|
64
|
+
elif key == "branch":
|
|
65
|
+
current["branch"] = _branch_name(value)
|
|
66
|
+
elif key in {"bare", "detached", "locked", "prunable"}:
|
|
67
|
+
current[key] = True # the optional reason text is free text and is dropped
|
|
68
|
+
if len(entries) >= MAX_WORKTREES:
|
|
69
|
+
break
|
|
70
|
+
flush()
|
|
71
|
+
return entries[:MAX_WORKTREES]
|
|
File without changes
|
cursorfleet/py.typed
ADDED
|
File without changes
|
cursorfleet/safeexe.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Locate external programs (``git``, ``cursor``) without trusting the current directory.
|
|
2
|
+
|
|
3
|
+
``shutil.which`` and ``CreateProcess`` search the current directory first on Windows, and
|
|
4
|
+
a ``PATH`` with an empty or ``.`` entry does the same on POSIX. CursorFleet is routinely run
|
|
5
|
+
inside repositories it does not control, so a repository that ships ``git.exe`` or
|
|
6
|
+
``cursor.cmd`` could otherwise get it executed (security review SR-05). :func:`find_executable`
|
|
7
|
+
searches only absolute ``PATH`` entries that are not the current directory, and returns an
|
|
8
|
+
absolute path, which callers then pass as ``argv[0]``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _search_dirs(path_env: str, cwd: str | None) -> list[str]:
|
|
18
|
+
real_cwd = os.path.normcase(os.path.realpath(cwd)) if cwd else None
|
|
19
|
+
found: list[str] = []
|
|
20
|
+
for entry in path_env.split(os.pathsep):
|
|
21
|
+
entry = entry.strip().strip('"') # noqa: PLW2901 - normalising the loop value
|
|
22
|
+
if not entry or entry == os.curdir or not os.path.isabs(entry):
|
|
23
|
+
continue
|
|
24
|
+
if os.path.normcase(os.path.realpath(entry)) == real_cwd:
|
|
25
|
+
continue
|
|
26
|
+
found.append(entry)
|
|
27
|
+
return found
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _candidate_names(name: str, pathext: str) -> list[str]:
|
|
31
|
+
if os.name != "nt":
|
|
32
|
+
return [name]
|
|
33
|
+
suffixes = [s for s in pathext.split(os.pathsep) if s] or [".EXE", ".CMD", ".BAT", ".COM"]
|
|
34
|
+
if os.path.splitext(name)[1].upper() in {s.upper() for s in suffixes}:
|
|
35
|
+
return [name]
|
|
36
|
+
return [name + s for s in suffixes]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache(maxsize=32)
|
|
40
|
+
def _lookup(name: str, path_value: str, pathext_value: str, cwd: str | None) -> str | None:
|
|
41
|
+
for directory in _search_dirs(path_value, cwd):
|
|
42
|
+
for candidate in _candidate_names(name, pathext_value):
|
|
43
|
+
full = os.path.join(directory, candidate)
|
|
44
|
+
if os.path.isfile(full) and os.access(full, os.X_OK):
|
|
45
|
+
return full
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_executable(
|
|
50
|
+
name: str,
|
|
51
|
+
*,
|
|
52
|
+
path_env: str | None = None,
|
|
53
|
+
pathext: str | None = None,
|
|
54
|
+
cwd: str | None = None,
|
|
55
|
+
) -> str | None:
|
|
56
|
+
"""Return the absolute path of ``name`` on ``PATH`` or ``None``. Never searches ``cwd``."""
|
|
57
|
+
if os.path.basename(name) != name:
|
|
58
|
+
return None # only bare program names are looked up
|
|
59
|
+
path_value = os.environ.get("PATH", "") if path_env is None else path_env
|
|
60
|
+
pathext_value = os.environ.get("PATHEXT", "") if pathext is None else pathext
|
|
61
|
+
if cwd is None:
|
|
62
|
+
try:
|
|
63
|
+
cwd = os.getcwd()
|
|
64
|
+
except OSError: # the directory was deleted under us: nothing to exclude
|
|
65
|
+
cwd = None
|
|
66
|
+
found = _lookup(name, path_value, pathext_value, cwd)
|
|
67
|
+
if found is not None and not os.path.isfile(found):
|
|
68
|
+
_lookup.cache_clear() # the cached program vanished: search again
|
|
69
|
+
return _lookup(name, path_value, pathext_value, cwd)
|
|
70
|
+
return found
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Generate the checked-in JSON Schemas from the Pydantic models.
|
|
2
|
+
|
|
3
|
+
Usage: ``uv run python scripts/gen_schemas.py [--check]``. ``--check`` exits 1 if
|
|
4
|
+
the files under ``schemas/`` differ from the models (also enforced by a test).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from cursorfleet.config.models import FleetConfig
|
|
17
|
+
from cursorfleet.config.roster import Roster
|
|
18
|
+
from cursorfleet.events.models import Event
|
|
19
|
+
|
|
20
|
+
DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
|
21
|
+
ID_BASE = "https://raw.githubusercontent.com/M9nx/cursorfleet/main/schemas/" # identifier only
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _from_model(model: type[BaseModel], filename: str, title: str) -> dict[str, Any]:
|
|
25
|
+
schema = model.model_json_schema(mode="validation")
|
|
26
|
+
schema["title"] = title
|
|
27
|
+
return {"$schema": DIALECT, "$id": ID_BASE + filename, **schema}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _policy_placeholder() -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"$schema": DIALECT,
|
|
33
|
+
"$id": ID_BASE + "policy.schema.json",
|
|
34
|
+
"title": "CursorFleet policy (reserved for v0.2)",
|
|
35
|
+
"description": (
|
|
36
|
+
"Placeholder. The policy engine (v0.2 Guard) does not exist in v0.1, so no policy "
|
|
37
|
+
"file is valid yet: this schema accepts only an empty object. Do not rely on its shape."
|
|
38
|
+
),
|
|
39
|
+
"x-cursorfleet-status": "reserved-for-v0.2",
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": False,
|
|
42
|
+
"properties": {},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generate_schemas() -> dict[str, dict[str, Any]]:
|
|
47
|
+
"""Return ``{filename: schema}`` for every schema we ship."""
|
|
48
|
+
return {
|
|
49
|
+
"config.schema.json": _from_model(FleetConfig, "config.schema.json", "CursorFleet config"),
|
|
50
|
+
"roster.schema.json": _from_model(Roster, "roster.schema.json", "CursorFleet roster"),
|
|
51
|
+
"event.schema.json": _from_model(Event, "event.schema.json", "CursorFleet event"),
|
|
52
|
+
"policy.schema.json": _policy_placeholder(),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render(schema: dict[str, Any]) -> str:
|
|
57
|
+
return json.dumps(schema, indent=2, ensure_ascii=False) + "\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(schemas_dir: Path, *, check: bool) -> int:
|
|
61
|
+
drifted: list[str] = []
|
|
62
|
+
for name, schema in generate_schemas().items():
|
|
63
|
+
target = schemas_dir / name
|
|
64
|
+
text = render(schema)
|
|
65
|
+
if check:
|
|
66
|
+
if not target.is_file() or json.loads(target.read_text("utf-8")) != schema:
|
|
67
|
+
drifted.append(name)
|
|
68
|
+
else:
|
|
69
|
+
target.write_text(text, encoding="utf-8")
|
|
70
|
+
if drifted:
|
|
71
|
+
sys.stderr.write(f"schema drift: {', '.join(drifted)} (run scripts/gen_schemas.py)\n")
|
|
72
|
+
return 1
|
|
73
|
+
return 0
|
|
File without changes
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Read-only scanner turning agent-declared artifacts into self-reported events (ADR 0006).
|
|
2
|
+
|
|
3
|
+
Artifacts (``<work dir>/<task>/<NN>-<kind>-<role>.md``, schema ``cursorfleet.artifact/1``)
|
|
4
|
+
are the only producer of ``plan.created``, ``handoff.created``, ``blocker.raised`` and
|
|
5
|
+
``context.loaded``. Everything here is SELF-REPORTED and untrusted:
|
|
6
|
+
|
|
7
|
+
- Only the validated frontmatter fields are kept (task slug, kind, author/target role,
|
|
8
|
+
``created``, ``issue_ref``, ``context_refs`` paths). The Markdown body is never stored,
|
|
9
|
+
hashed, logged or displayed; there are no title or summary fields in the schema.
|
|
10
|
+
- An invalid artifact becomes an :class:`ArtifactIssue` carrying a *fixed* description for
|
|
11
|
+
its problem code, never the parser's message (which may quote file content). The scan
|
|
12
|
+
never raises: unreadable, oversized, symlinked and malformed files are reported.
|
|
13
|
+
- The scan never writes, follows symlinks, or reads past the size cap, and stops after
|
|
14
|
+
``MAX_ARTIFACT_FILES`` files.
|
|
15
|
+
|
|
16
|
+
Events derived from records use ``producer=work_artifact``, ``source=self_reported`` and
|
|
17
|
+
``attribution=unknown`` (``author_role`` is spoofable) in the synthetic session
|
|
18
|
+
``work:<task>``. Ids are deterministic (path + frontmatter hash), so rescans and the same
|
|
19
|
+
file appearing in several worktrees dedupe.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import os
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import PurePosixPath
|
|
30
|
+
|
|
31
|
+
from pydantic import ValidationError
|
|
32
|
+
|
|
33
|
+
from cursorfleet import __version__
|
|
34
|
+
from cursorfleet.events.ids import new_event_id
|
|
35
|
+
from cursorfleet.events.models import Event
|
|
36
|
+
from cursorfleet.state.models import utc
|
|
37
|
+
from cursorfleet.workflow.artifacts import MAX_ARTIFACT_BYTES, validate_artifact_text
|
|
38
|
+
from cursorfleet.workflow.frontmatter import FrontmatterError, parse_frontmatter
|
|
39
|
+
|
|
40
|
+
DEFAULT_WORK_DIR = ".cursorfleet/work"
|
|
41
|
+
MAX_ARTIFACT_FILES = 2000
|
|
42
|
+
SESSION_PREFIX = "work:"
|
|
43
|
+
|
|
44
|
+
_MIN_TS = datetime(1971, 1, 1, tzinfo=UTC)
|
|
45
|
+
_MAX_TS = datetime(2200, 1, 1, tzinfo=UTC)
|
|
46
|
+
|
|
47
|
+
# Fixed, content-free descriptions. The parser's own messages can quote file content.
|
|
48
|
+
PROBLEM_TEXT: dict[str, str] = {
|
|
49
|
+
"artifact.too_large": "file is larger than the 64 KiB artifact limit",
|
|
50
|
+
"artifact.frontmatter": "frontmatter is missing or not in the strict subset",
|
|
51
|
+
"artifact.missing_key": "a required frontmatter key is missing",
|
|
52
|
+
"artifact.unknown_key": "frontmatter has a key outside the schema",
|
|
53
|
+
"artifact.schema": "schema is not a supported cursorfleet.artifact version",
|
|
54
|
+
"artifact.kind": "kind is not plan/handoff/blocker/context",
|
|
55
|
+
"artifact.location": "artifact must live in <work dir>/<task>/<file>.md",
|
|
56
|
+
"artifact.task": "task is not a slug equal to the directory name",
|
|
57
|
+
"artifact.author_role": "author_role is not a safe id",
|
|
58
|
+
"artifact.to_role": "to_role is not a safe id",
|
|
59
|
+
"artifact.created": "created is not an ISO 8601 timestamp in a sane range",
|
|
60
|
+
"artifact.issue_ref": "issue_ref is not short printable text",
|
|
61
|
+
"artifact.context_refs": "context_refs is not a list of workspace-relative paths",
|
|
62
|
+
"artifact.symlink": "symlinks are not read",
|
|
63
|
+
"artifact.unreadable": "file could not be read",
|
|
64
|
+
"artifact.encoding": "file is not valid UTF-8",
|
|
65
|
+
"artifact.too_many": f"scan stopped after {MAX_ARTIFACT_FILES} files",
|
|
66
|
+
"artifact.event_invalid": "frontmatter values do not fit the event model",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ArtifactRecord:
|
|
72
|
+
"""Validated frontmatter of one artifact. Self-reported; the body is not kept."""
|
|
73
|
+
|
|
74
|
+
path: str # relative to the work dir, "/" separated: "<task>/<file>.md"
|
|
75
|
+
task: str
|
|
76
|
+
kind: str # plan.created | handoff.created | blocker.raised | context.loaded
|
|
77
|
+
author_role: str
|
|
78
|
+
created: datetime
|
|
79
|
+
to_role: str | None = None
|
|
80
|
+
issue_ref: str | None = None
|
|
81
|
+
context_refs: tuple[str, ...] = ()
|
|
82
|
+
fingerprint: str = "" # sha256(path + frontmatter)[:16]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def session_id(self) -> str:
|
|
86
|
+
return SESSION_PREFIX + self.task
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ArtifactIssue:
|
|
91
|
+
path: str # relative to the work dir
|
|
92
|
+
code: str
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def message(self) -> str:
|
|
96
|
+
return PROBLEM_TEXT.get(self.code, "artifact problem")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class ArtifactScan:
|
|
101
|
+
records: list[ArtifactRecord] = field(default_factory=list)
|
|
102
|
+
issues: list[ArtifactIssue] = field(default_factory=list)
|
|
103
|
+
events: list[Event] = field(default_factory=list) # self-reported, one per valid record
|
|
104
|
+
files: int = 0 # candidate files examined
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def record_to_event(record: ArtifactRecord) -> Event | None:
|
|
108
|
+
"""Map a record onto the event model; ``None`` if the values do not fit it."""
|
|
109
|
+
millis = int(record.created.timestamp() * 1000)
|
|
110
|
+
seed = hashlib.sha256(f"{record.path}\0{record.fingerprint}".encode()).digest()[:10]
|
|
111
|
+
refs: list[dict[str, str]] = [{"path": ref} for ref in record.context_refs]
|
|
112
|
+
data: dict[str, object] = {
|
|
113
|
+
"event_id": new_event_id(millis, seed),
|
|
114
|
+
"ts": record.created.isoformat(),
|
|
115
|
+
"producer": "work_artifact",
|
|
116
|
+
"producer_version": __version__,
|
|
117
|
+
"source": "self_reported",
|
|
118
|
+
"kind": record.kind,
|
|
119
|
+
"session_id": record.session_id,
|
|
120
|
+
"agent_role": record.author_role,
|
|
121
|
+
"attribution": "unknown",
|
|
122
|
+
"paths": refs,
|
|
123
|
+
}
|
|
124
|
+
if record.issue_ref is not None:
|
|
125
|
+
data["issue_ref"] = record.issue_ref
|
|
126
|
+
try:
|
|
127
|
+
return Event.model_validate(data)
|
|
128
|
+
except ValidationError:
|
|
129
|
+
# issue_ref allows more characters than the event field does: retry without it.
|
|
130
|
+
data.pop("issue_ref", None)
|
|
131
|
+
try:
|
|
132
|
+
return Event.model_validate(data)
|
|
133
|
+
except ValidationError:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_created(value: object) -> datetime | None:
|
|
138
|
+
if not isinstance(value, str):
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
142
|
+
except ValueError:
|
|
143
|
+
return None
|
|
144
|
+
if parsed.tzinfo is None:
|
|
145
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
146
|
+
parsed = utc(parsed)
|
|
147
|
+
return parsed if _MIN_TS <= parsed <= _MAX_TS else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _record_from_text(text: str, rel: str) -> ArtifactRecord | list[str]:
|
|
151
|
+
"""Validate ``text``; return the record, or the list of problem codes."""
|
|
152
|
+
problems = [p.code for p in validate_artifact_text(text, rel)]
|
|
153
|
+
if problems:
|
|
154
|
+
return sorted(set(problems))
|
|
155
|
+
try:
|
|
156
|
+
doc = parse_frontmatter(text)
|
|
157
|
+
except FrontmatterError: # pragma: no cover - validate_artifact_text already parsed it
|
|
158
|
+
return ["artifact.frontmatter"]
|
|
159
|
+
meta = doc.meta
|
|
160
|
+
created = _parse_created(meta.get("created"))
|
|
161
|
+
if created is None:
|
|
162
|
+
return ["artifact.created"]
|
|
163
|
+
refs_raw = meta.get("context_refs", [])
|
|
164
|
+
refs = tuple(r for r in refs_raw if isinstance(r, str)) if isinstance(refs_raw, list) else ()
|
|
165
|
+
to_role = meta.get("to_role")
|
|
166
|
+
issue = meta.get("issue_ref")
|
|
167
|
+
# Fingerprint covers the frontmatter only, so editing the body never changes identity.
|
|
168
|
+
frontmatter_only = text.replace("\r\n", "\n").split("\n---", 2)[0]
|
|
169
|
+
fingerprint = hashlib.sha256(f"{rel}\0{frontmatter_only}".encode()).hexdigest()[:16]
|
|
170
|
+
return ArtifactRecord(
|
|
171
|
+
path=rel,
|
|
172
|
+
task=str(meta["task"]),
|
|
173
|
+
kind=str(meta["kind"]),
|
|
174
|
+
author_role=str(meta["author_role"]),
|
|
175
|
+
created=created,
|
|
176
|
+
to_role=to_role if isinstance(to_role, str) else None,
|
|
177
|
+
issue_ref=issue if isinstance(issue, str) else None,
|
|
178
|
+
context_refs=refs,
|
|
179
|
+
fingerprint=fingerprint,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_CacheValue = tuple[int, int, "ArtifactRecord | list[str]"]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ArtifactScanner:
|
|
187
|
+
"""Scans the work dir of one or more worktree roots; caches by ``(mtime_ns, size)``."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, work_rel: str = DEFAULT_WORK_DIR) -> None:
|
|
190
|
+
self._work_rel = work_rel
|
|
191
|
+
self._cache: dict[str, _CacheValue] = {}
|
|
192
|
+
|
|
193
|
+
def scan(self, roots: Sequence[str]) -> ArtifactScan:
|
|
194
|
+
"""Scan every root (first one wins on duplicates). Never raises."""
|
|
195
|
+
result = ArtifactScan()
|
|
196
|
+
seen: set[tuple[str, str]] = set()
|
|
197
|
+
live: set[str] = set()
|
|
198
|
+
for root in dict.fromkeys(roots):
|
|
199
|
+
try:
|
|
200
|
+
self._scan_root(root, result, seen, live)
|
|
201
|
+
except OSError:
|
|
202
|
+
continue
|
|
203
|
+
if result.files > MAX_ARTIFACT_FILES:
|
|
204
|
+
break
|
|
205
|
+
for stale in set(self._cache) - live:
|
|
206
|
+
del self._cache[stale]
|
|
207
|
+
result.records.sort(key=lambda r: (r.created, r.path))
|
|
208
|
+
for record in result.records:
|
|
209
|
+
event = record_to_event(record)
|
|
210
|
+
if event is None:
|
|
211
|
+
result.issues.append(ArtifactIssue(record.path, "artifact.event_invalid"))
|
|
212
|
+
else:
|
|
213
|
+
result.events.append(event)
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------ internals
|
|
217
|
+
|
|
218
|
+
def _scan_root(
|
|
219
|
+
self, root: str, result: ArtifactScan, seen: set[tuple[str, str]], live: set[str]
|
|
220
|
+
) -> None:
|
|
221
|
+
base = os.path.join(root, *self._work_rel.split("/"))
|
|
222
|
+
if not os.path.isdir(base) or os.path.islink(base):
|
|
223
|
+
return
|
|
224
|
+
for rel, full in self._candidates(base, result):
|
|
225
|
+
if result.files >= MAX_ARTIFACT_FILES:
|
|
226
|
+
result.issues.append(ArtifactIssue(rel, "artifact.too_many"))
|
|
227
|
+
result.files += 1 # sentinel: stop the outer loop
|
|
228
|
+
return
|
|
229
|
+
result.files += 1
|
|
230
|
+
live.add(full)
|
|
231
|
+
outcome = self._load(full, rel)
|
|
232
|
+
if isinstance(outcome, list):
|
|
233
|
+
result.issues.extend(ArtifactIssue(rel, code) for code in outcome)
|
|
234
|
+
elif (outcome.path, outcome.fingerprint) not in seen:
|
|
235
|
+
seen.add((outcome.path, outcome.fingerprint))
|
|
236
|
+
result.records.append(outcome)
|
|
237
|
+
|
|
238
|
+
def _candidates(self, base: str, result: ArtifactScan) -> list[tuple[str, str]]:
|
|
239
|
+
found: list[tuple[str, str]] = []
|
|
240
|
+
for entry in sorted(_scandir(base), key=lambda e: e.name):
|
|
241
|
+
if entry.is_symlink():
|
|
242
|
+
result.issues.append(ArtifactIssue(entry.name, "artifact.symlink"))
|
|
243
|
+
continue
|
|
244
|
+
if entry.is_file(follow_symlinks=False):
|
|
245
|
+
if entry.name.endswith(".md") and entry.name != "AGENTS.md":
|
|
246
|
+
found.append((entry.name, entry.path)) # not under a task directory
|
|
247
|
+
continue
|
|
248
|
+
if not entry.is_dir(follow_symlinks=False):
|
|
249
|
+
continue
|
|
250
|
+
for inner in sorted(_scandir(entry.path), key=lambda e: e.name):
|
|
251
|
+
rel = str(PurePosixPath(entry.name, inner.name))
|
|
252
|
+
if not inner.name.endswith(".md") or inner.name == "AGENTS.md":
|
|
253
|
+
continue
|
|
254
|
+
if inner.is_symlink():
|
|
255
|
+
result.issues.append(ArtifactIssue(rel, "artifact.symlink"))
|
|
256
|
+
elif inner.is_file(follow_symlinks=False):
|
|
257
|
+
found.append((rel, inner.path))
|
|
258
|
+
return found
|
|
259
|
+
|
|
260
|
+
def _load(self, full: str, rel: str) -> ArtifactRecord | list[str]:
|
|
261
|
+
try:
|
|
262
|
+
st = os.stat(full, follow_symlinks=False)
|
|
263
|
+
except OSError:
|
|
264
|
+
return ["artifact.unreadable"]
|
|
265
|
+
cached = self._cache.get(full)
|
|
266
|
+
if cached is not None and cached[0] == st.st_mtime_ns and cached[1] == st.st_size:
|
|
267
|
+
return cached[2]
|
|
268
|
+
outcome = self._read(full, rel, st.st_size)
|
|
269
|
+
self._cache[full] = (st.st_mtime_ns, st.st_size, outcome)
|
|
270
|
+
return outcome
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _read(full: str, rel: str, size: int) -> ArtifactRecord | list[str]:
|
|
274
|
+
if size > MAX_ARTIFACT_BYTES:
|
|
275
|
+
return ["artifact.too_large"]
|
|
276
|
+
# O_NOFOLLOW: a file swapped for a symlink after the scan's lstat is refused (ELOOP).
|
|
277
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
|
278
|
+
try:
|
|
279
|
+
fd = os.open(full, flags)
|
|
280
|
+
with os.fdopen(fd, "rb") as handle:
|
|
281
|
+
raw = handle.read(MAX_ARTIFACT_BYTES + 1)
|
|
282
|
+
except OSError:
|
|
283
|
+
return ["artifact.unreadable"]
|
|
284
|
+
try:
|
|
285
|
+
text = raw.decode("utf-8")
|
|
286
|
+
except UnicodeDecodeError:
|
|
287
|
+
return ["artifact.encoding"]
|
|
288
|
+
return _record_from_text(text, rel)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _scandir(path: str) -> list[os.DirEntry[str]]:
|
|
292
|
+
try:
|
|
293
|
+
with os.scandir(path) as it:
|
|
294
|
+
return list(it)
|
|
295
|
+
except OSError:
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
__all__ = [
|
|
300
|
+
"DEFAULT_WORK_DIR",
|
|
301
|
+
"MAX_ARTIFACT_FILES",
|
|
302
|
+
"PROBLEM_TEXT",
|
|
303
|
+
"SESSION_PREFIX",
|
|
304
|
+
"ArtifactIssue",
|
|
305
|
+
"ArtifactRecord",
|
|
306
|
+
"ArtifactScan",
|
|
307
|
+
"ArtifactScanner",
|
|
308
|
+
"record_to_event",
|
|
309
|
+
]
|