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,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
+ ]