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,197 @@
1
+ """``cursorfleet events purge`` and ``events export --sanitized`` (docs/privacy.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from pydantic import ValidationError
12
+
13
+ from cursorfleet.adapters.cursor.fsutil import safe_text
14
+ from cursorfleet.config.io import loads_config
15
+ from cursorfleet.events.models import Event
16
+ from cursorfleet.git.runner import NotAGitRepo
17
+ from cursorfleet.state.context import RepoContext, parse_now, resolve_repo
18
+ from cursorfleet.state.indexer import Indexer, IndexerBusy
19
+ from cursorfleet.state.reducer import dedupe_and_sort
20
+ from cursorfleet.state.retention import (
21
+ DEFAULT_MAX_AGE_DAYS,
22
+ DEFAULT_MAX_SESSION_MB,
23
+ PurgePlan,
24
+ apply_plan,
25
+ parse_duration,
26
+ plan_purge,
27
+ )
28
+ from cursorfleet.state.spool_read import read_all
29
+
30
+ RepoOption = Annotated[
31
+ Path, typer.Option("--repo", help="Directory inside the git repo (default: cwd).")
32
+ ]
33
+
34
+
35
+ def _retention(ctx: RepoContext) -> tuple[int, int]:
36
+ """Configured (max_age_days, max_session_mb); defaults if absent or invalid."""
37
+ if ctx.top_level is None:
38
+ return DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SESSION_MB
39
+ path = Path(ctx.top_level) / ".cursorfleet" / "config.toml"
40
+ try:
41
+ config = loads_config(path.read_text(encoding="utf-8"))
42
+ except FileNotFoundError:
43
+ return DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SESSION_MB
44
+ except (OSError, ValueError) as exc:
45
+ typer.echo(
46
+ f"warning: ignoring unreadable .cursorfleet/config.toml ({exc.__class__.__name__})",
47
+ err=True,
48
+ )
49
+ return DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SESSION_MB
50
+ return config.retention.max_age_days, config.retention.max_session_mb
51
+
52
+
53
+ def _describe(plan: PurgePlan) -> str:
54
+ extras = []
55
+ if plan.remove_projection:
56
+ extras.append("projection")
57
+ if plan.rotate_key:
58
+ extras.append("hashing key (rotated)")
59
+ if plan.remove_quarantine:
60
+ extras.append("quarantine")
61
+ tail = f"; also: {', '.join(extras)}" if extras else ""
62
+ return f"{plan.sessions} session(s), {len(plan.files)} file(s), {plan.file_bytes} byte(s){tail}"
63
+
64
+
65
+ def purge( # noqa: PLR0913
66
+ *,
67
+ older_than: Annotated[
68
+ str | None, typer.Option("--older-than", help="Duration such as 7d, 12h, 90m.")
69
+ ] = None,
70
+ session: Annotated[str | None, typer.Option("--session", help="One session id.")] = None,
71
+ all_: Annotated[
72
+ bool,
73
+ typer.Option("--all", help="Remove every spool file and the projection; rotate the key."),
74
+ ] = False,
75
+ dry_run: Annotated[
76
+ bool, typer.Option("--dry-run", help="Print counts, delete nothing.")
77
+ ] = False,
78
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
79
+ repo: RepoOption = Path(),
80
+ now: Annotated[str | None, typer.Option("--now", hidden=True)] = None,
81
+ ) -> None:
82
+ """Delete spool data under the git common dir. Never touches committed config.
83
+
84
+ With no selector the configured retention is applied (default 14 days or 50 MB per
85
+ session). Exit 0 on success (including nothing to purge), 1 on error, 2 on bad usage.
86
+ """
87
+ if sum([older_than is not None, session is not None, all_]) > 1:
88
+ typer.echo("error: use at most one of --older-than, --session, --all", err=True)
89
+ raise typer.Exit(2)
90
+ try:
91
+ delta = parse_duration(older_than) if older_than is not None else None
92
+ clock = parse_now(now)
93
+ except ValueError as exc:
94
+ typer.echo(f"error: {safe_text(str(exc))}", err=True)
95
+ raise typer.Exit(2) from exc
96
+ try:
97
+ ctx = resolve_repo(repo)
98
+ except NotAGitRepo as exc:
99
+ typer.echo(f"error: {safe_text(str(exc))}", err=True)
100
+ raise typer.Exit(1) from exc
101
+ max_age, max_mb = _retention(ctx)
102
+ plan = plan_purge(
103
+ ctx.paths,
104
+ now=clock,
105
+ older_than=delta,
106
+ session=session,
107
+ everything=all_,
108
+ max_age_days=max_age,
109
+ max_session_mb=max_mb,
110
+ )
111
+ if plan.empty:
112
+ typer.echo("nothing to purge")
113
+ return
114
+ if dry_run:
115
+ typer.echo(f"dry run: would remove {_describe(plan)}")
116
+ return
117
+ _confirm(yes, plan)
118
+ report = apply_plan(ctx.paths, plan)
119
+ if report.needs_reindex and not report.projection_removed:
120
+ try:
121
+ Indexer(ctx.paths).sync(rebuild=True)
122
+ except IndexerBusy:
123
+ typer.echo("note: another indexer is running; run `cursorfleet index --rebuild` later")
124
+ typer.echo(
125
+ f"removed {report.sessions} session(s), {report.files} file(s), {report.bytes} byte(s)"
126
+ + ("; projection removed" if report.projection_removed else "")
127
+ + ("; hashing key rotated" if report.key_rotated else "")
128
+ )
129
+ for problem in report.errors:
130
+ typer.echo(f"warning: {problem}", err=True)
131
+ if report.errors:
132
+ raise typer.Exit(1)
133
+
134
+
135
+ def _confirm(yes: bool, plan: PurgePlan) -> None:
136
+ if yes:
137
+ return
138
+ if not sys.stdin.isatty():
139
+ typer.echo("error: refusing to delete without --yes when not interactive", err=True)
140
+ raise typer.Exit(2)
141
+ if not typer.confirm(f"Remove {_describe(plan)}?", default=False):
142
+ typer.echo("aborted")
143
+ raise typer.Exit(1)
144
+
145
+
146
+ def export(
147
+ *,
148
+ sanitized: Annotated[
149
+ bool, typer.Option("--sanitized", help="Required: export only re-validated events.")
150
+ ] = False,
151
+ session: Annotated[str | None, typer.Option("--session")] = None,
152
+ output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
153
+ repo: RepoOption = Path(),
154
+ ) -> None:
155
+ """Export spool events as JSON Lines after re-validating each one with the Event model.
156
+
157
+ Events are de-duplicated and ordered by (ts, event_id). Invalid or corrupt lines are
158
+ skipped and counted on stderr. Check branch names and paths before sharing.
159
+ """
160
+ if not sanitized:
161
+ typer.echo("error: only --sanitized export exists; raw export is not offered", err=True)
162
+ raise typer.Exit(2)
163
+ try:
164
+ ctx = resolve_repo(repo)
165
+ except NotAGitRepo as exc:
166
+ typer.echo(f"error: {safe_text(str(exc))}", err=True)
167
+ raise typer.Exit(1) from exc
168
+ spooled, corruption, _files = read_all(ctx.paths, session)
169
+ lines: list[str] = []
170
+ rejected = 0
171
+ for event in dedupe_and_sort(spooled):
172
+ dumped = event.model_dump_json(exclude_none=True)
173
+ try:
174
+ Event.model_validate(json.loads(dumped)) # re-validate the exact bytes we emit
175
+ except (ValidationError, ValueError):
176
+ rejected += 1
177
+ continue
178
+ lines.append(dumped)
179
+ text = "".join(line + "\n" for line in lines)
180
+ if output is None:
181
+ sys.stdout.write(text)
182
+ else:
183
+ with output.open("w", encoding="utf-8", newline="\n") as handle:
184
+ handle.write(text)
185
+ skipped = corruption.total + rejected
186
+ typer.echo(f"exported {len(lines)} event(s); skipped {skipped} corrupt/invalid", err=True)
187
+
188
+
189
+ def register(app: typer.Typer) -> None:
190
+ events = typer.Typer(
191
+ name="events",
192
+ help="Inspect, export and purge the local event spool.",
193
+ no_args_is_help=True,
194
+ )
195
+ events.command("purge")(purge)
196
+ events.command("export")(export)
197
+ app.add_typer(events, name="events")
@@ -0,0 +1,55 @@
1
+ """``cursorfleet index [--rebuild]``: run the single-writer indexer once."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from cursorfleet.adapters.cursor.fsutil import safe_text
12
+ from cursorfleet.git.runner import NotAGitRepo
13
+ from cursorfleet.state.context import resolve_repo
14
+ from cursorfleet.state.indexer import Indexer, IndexerBusy
15
+
16
+
17
+ def register(app: typer.Typer) -> None:
18
+ @app.command("index")
19
+ def index(
20
+ rebuild: Annotated[
21
+ bool,
22
+ typer.Option("--rebuild", help="Delete the projection and rebuild from the spool."),
23
+ ] = False,
24
+ json_output: Annotated[bool, typer.Option("--json")] = False,
25
+ repo: Annotated[Path, typer.Option("--repo")] = Path(),
26
+ ) -> None:
27
+ """Tail the spool into the SQLite projection once (the projection is rebuildable)."""
28
+ try:
29
+ ctx = resolve_repo(repo)
30
+ except NotAGitRepo as exc:
31
+ typer.echo(f"error: {safe_text(str(exc))}", err=True)
32
+ raise typer.Exit(1) from exc
33
+ try:
34
+ stats = Indexer(ctx.paths).sync(rebuild=rebuild)
35
+ except IndexerBusy as exc:
36
+ typer.echo(f"error: {safe_text(str(exc))}", err=True)
37
+ raise typer.Exit(1) from exc
38
+ summary = {
39
+ "files_scanned": stats.files_scanned,
40
+ "events_read": stats.events_read,
41
+ "events_new": stats.events_new,
42
+ "sessions_updated": stats.sessions_updated,
43
+ "sessions_rebuilt": stats.sessions_rebuilt,
44
+ "sessions_pruned": stats.sessions_pruned,
45
+ "db_recovered": stats.db_recovered,
46
+ "corruption": stats.corruption.to_dict(),
47
+ }
48
+ if json_output:
49
+ typer.echo(json.dumps({"schema": "cursorfleet.index/1", **summary}, indent=2))
50
+ else:
51
+ typer.echo(
52
+ f"indexed {stats.events_new} new event(s) from {stats.files_scanned} file(s); "
53
+ f"{stats.sessions_updated} updated, {stats.sessions_rebuilt} rebuilt, "
54
+ f"{stats.corruption.total} corrupt/skipped"
55
+ )
@@ -0,0 +1,71 @@
1
+ """``cursorfleet init --cursor``: install the Cursor kit with a diff and confirmation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from cursorfleet.adapters.cursor import installer
11
+ from cursorfleet.adapters.cursor.fsutil import UnsafePathError, safe_text
12
+ from cursorfleet.adapters.cursor.hooksjson import HOOK_COMMAND
13
+ from cursorfleet.cli.commands._common import fail, show_plan, workspace_root
14
+
15
+
16
+ def register(app: typer.Typer) -> None:
17
+ @app.command("init")
18
+ def init(
19
+ cursor: Annotated[
20
+ bool, typer.Option("--cursor", help="Install the kit for Cursor (the only target).")
21
+ ] = False,
22
+ dry_run: Annotated[
23
+ bool, typer.Option("--dry-run", help="Print the diff and write nothing.")
24
+ ] = False,
25
+ yes: Annotated[
26
+ bool,
27
+ typer.Option("--yes", "-y", help="Skip the prompt (the diff is still printed)."),
28
+ ] = False,
29
+ path: Annotated[
30
+ Path,
31
+ typer.Option("--path", help="Repository root (default: current directory)."),
32
+ ] = Path(),
33
+ ) -> None:
34
+ """Generate subagents, skills, rules, AGENTS.md blocks and observe-only hooks.
35
+
36
+ Prints an exact diff of every file (including .cursor/hooks.json), then asks for
37
+ confirmation. Existing hooks and AGENTS.md content are preserved. Undo with
38
+ `cursorfleet uninstall`.
39
+ """
40
+ if not cursor:
41
+ raise fail("error: choose a target, e.g. `cursorfleet init --cursor`.", 2)
42
+ root = workspace_root(path, command="init")
43
+ plan = installer.build_install_plan(root)
44
+ if plan.conflicts:
45
+ typer.echo(
46
+ "Cannot install; resolve these conflicts first (nothing was written):", err=True
47
+ )
48
+ for conflict in plan.conflicts:
49
+ typer.echo(f" - {safe_text(conflict)}", err=True)
50
+ raise typer.Exit(1)
51
+ if plan.noop:
52
+ typer.echo("Already up to date: nothing to do.")
53
+ return
54
+ show_plan(plan)
55
+ if plan.touches_hooks:
56
+ typer.echo(
57
+ f"\nWARNING: this edits .cursor/hooks.json. Cursor runs `{HOOK_COMMAND}` on the "
58
+ "registered events (observe-only: it records sanitized metadata and always "
59
+ "exits 0). Review the hooks.json diff above."
60
+ )
61
+ if dry_run:
62
+ typer.echo("\nDry run: no files were written.")
63
+ return
64
+ if not yes and not typer.confirm("\nApply these changes?", default=False):
65
+ typer.echo("Aborted; nothing was written.")
66
+ raise typer.Exit(1)
67
+ try:
68
+ installer.apply_plan(plan)
69
+ except (OSError, UnsafePathError) as exc:
70
+ raise fail(f"error while writing: {exc}\nRun `cursorfleet doctor` to inspect.") from exc
71
+ typer.echo("\nInstalled. Next: `cursorfleet doctor`. Undo with `cursorfleet uninstall`.")
@@ -0,0 +1,43 @@
1
+ """``cursorfleet migrate`` — offline migrations (artifacts, etc.)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from cursorfleet.state.artifact_scan import DEFAULT_WORK_DIR, ArtifactScanner
11
+ from cursorfleet.workflow.artifacts import ARTIFACT_SCHEMA, ARTIFACT_SCHEMA_LEGACY
12
+
13
+ migrate_app = typer.Typer(name="migrate", help="Inspect or migrate local CursorFleet data.")
14
+
15
+
16
+ def register(app: typer.Typer) -> None:
17
+ app.add_typer(migrate_app)
18
+
19
+ @migrate_app.command("artifacts")
20
+ def artifacts(
21
+ path: Annotated[Path, typer.Option("--path", help="Repository root.")] = Path(),
22
+ dry_run: Annotated[
23
+ bool, typer.Option("--dry-run", help="Report only; write nothing.")
24
+ ] = True,
25
+ yes: Annotated[
26
+ bool, typer.Option("--yes", help="Apply migrations (not implemented).")
27
+ ] = False,
28
+ ) -> None:
29
+ """List legacy YAML artifacts vs TOML 0.1 (dry-run by default)."""
30
+ root = path.resolve()
31
+ work = root / DEFAULT_WORK_DIR
32
+ if not work.is_dir():
33
+ typer.echo("no work directory")
34
+ return
35
+ scan = ArtifactScanner(str(work))
36
+ result = scan.scan([str(root)])
37
+ typer.echo(f"valid artifacts: {len(result.records)} issues: {len(result.issues)}")
38
+ typer.echo(f"target schema: {ARTIFACT_SCHEMA}")
39
+ typer.echo(f"legacy accepted: {ARTIFACT_SCHEMA_LEGACY}")
40
+ if dry_run or not yes:
41
+ typer.echo("dry-run: no files changed (TOML rewrite is manual or future --yes)")
42
+ return
43
+ typer.echo("nothing to apply yet")
@@ -0,0 +1,76 @@
1
+ """``cursorfleet replay <session-id> [--json]``: rebuild from the spool, prove determinism."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from cursorfleet.adapters.cursor.fsutil import safe_text
12
+ from cursorfleet.git.runner import NotAGitRepo
13
+ from cursorfleet.state.context import parse_now, resolve_repo
14
+ from cursorfleet.state.reducer import digest, reduce_events, session_view
15
+ from cursorfleet.state.spool_read import read_all
16
+
17
+
18
+ def register(app: typer.Typer) -> None:
19
+ @app.command("replay")
20
+ def replay(
21
+ session_id: Annotated[str, typer.Argument(help="Cursor conversation/session id.")],
22
+ json_output: Annotated[
23
+ bool, typer.Option("--json", help="Machine-readable output.")
24
+ ] = False,
25
+ repo: Annotated[
26
+ Path, typer.Option("--repo", help="Directory inside the git repo (default: cwd).")
27
+ ] = Path(),
28
+ stale_after_minutes: Annotated[int, typer.Option("--stale-after-minutes", min=1)] = 15,
29
+ now: Annotated[
30
+ str | None, typer.Option("--now", hidden=True, help="ISO-8601 clock for tests.")
31
+ ] = None,
32
+ ) -> None:
33
+ """Rebuild one session's state from the spool, twice, and check both runs agree.
34
+
35
+ Exit 0 if the two replays are identical, 1 if the session is unknown or the
36
+ replays differ (a bug), 2 on usage errors. Corrupt spool lines are counted, never fatal.
37
+ """
38
+ try:
39
+ clock = parse_now(now)
40
+ except ValueError as exc:
41
+ typer.echo(f"error: {safe_text(str(exc))}", err=True)
42
+ raise typer.Exit(2) from exc
43
+ try:
44
+ ctx = resolve_repo(repo)
45
+ except NotAGitRepo as exc:
46
+ typer.echo(f"error: {safe_text(str(exc))}", err=True)
47
+ raise typer.Exit(1) from exc
48
+ events_a, corruption, files = read_all(ctx.paths, session_id)
49
+ events_b, _c, _f = read_all(ctx.paths, session_id)
50
+ first = reduce_events(events_a).get(session_id)
51
+ second = reduce_events(reversed(events_b)).get(session_id)
52
+ if first is None or second is None:
53
+ typer.echo(f"error: no events for session {session_id!r} in the spool", err=True)
54
+ raise typer.Exit(1)
55
+ deterministic = digest(first) == digest(second)
56
+ view = session_view(first, clock, stale_after_minutes * 60)
57
+ if json_output:
58
+ payload = {
59
+ "schema": "cursorfleet.replay/1",
60
+ "session_id": session_id,
61
+ "deterministic": deterministic,
62
+ "digest": digest(first),
63
+ "spool_files": files,
64
+ "events": len(events_a),
65
+ "corruption": corruption.to_dict(),
66
+ "state": json.loads(view.model_dump_json()),
67
+ }
68
+ typer.echo(json.dumps(payload, indent=2))
69
+ else:
70
+ typer.echo(
71
+ f"session {session_id}: {len(events_a)} event(s) from {files} file(s); "
72
+ f"lane={view.lane}; corrupt/skipped={corruption.total}"
73
+ )
74
+ typer.echo(f"digest {digest(first)} deterministic={str(deterministic).lower()}")
75
+ if not deterministic:
76
+ raise typer.Exit(1)
@@ -0,0 +1,97 @@
1
+ """``cursorfleet run`` — observe-only run metadata (runtime dir only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from cursorfleet.git.runner import NotAGitRepo
13
+ from cursorfleet.state.context import resolve_repo
14
+ from cursorfleet.state.run_store import RunRecord, attach_session, list_runs, load_run, save_run
15
+
16
+ run_app = typer.Typer(name="run", help="Local run bookkeeping (does not modify git or hooks).")
17
+
18
+
19
+ def register(app: typer.Typer) -> None:
20
+ app.add_typer(run_app)
21
+
22
+ @run_app.command("start")
23
+ def start(
24
+ task: Annotated[
25
+ str, typer.Option("--task", help="Task slug under .cursorfleet/work/<task>/")
26
+ ],
27
+ path: Annotated[
28
+ Path, typer.Option("--path", help="Repository root or inside it.")
29
+ ] = Path(),
30
+ run_id: Annotated[
31
+ str | None, typer.Option("--run-id", help="Optional stable id; default: random.")
32
+ ] = None,
33
+ ) -> None:
34
+ try:
35
+ ctx = resolve_repo(path.resolve())
36
+ except NotAGitRepo:
37
+ typer.echo("error: not a git repository", err=True)
38
+ raise typer.Exit(2) from None
39
+ rid = run_id or f"run-{uuid.uuid4().hex[:12]}"
40
+ record = RunRecord(
41
+ run_id=rid,
42
+ task_slug=task,
43
+ created_at=datetime.now(tz=UTC).isoformat(),
44
+ )
45
+ save_run(ctx.paths.root, record)
46
+ typer.echo(rid)
47
+
48
+ @run_app.command("list")
49
+ def list_cmd(
50
+ path: Annotated[Path, typer.Option("--path")] = Path(),
51
+ ) -> None:
52
+ try:
53
+ ctx = resolve_repo(path.resolve())
54
+ except NotAGitRepo:
55
+ typer.echo("error: not a git repository", err=True)
56
+ raise typer.Exit(2) from None
57
+ rows = list_runs(ctx.paths.root)
58
+ if not rows:
59
+ typer.echo("no runs")
60
+ return
61
+ for row in rows:
62
+ flag = "archived" if row.archived else "active"
63
+ typer.echo(f"{row.run_id}\t{row.task_slug}\t{flag}\tsessions={len(row.session_ids)}")
64
+
65
+ @run_app.command("attach")
66
+ def attach(
67
+ run_id: Annotated[str, typer.Argument(help="Run id from run start")],
68
+ session: Annotated[str, typer.Option("--session", help="Cursor session_id to attach.")],
69
+ path: Annotated[Path, typer.Option("--path")] = Path(),
70
+ ) -> None:
71
+ try:
72
+ ctx = resolve_repo(path.resolve())
73
+ except NotAGitRepo:
74
+ typer.echo("error: not a git repository", err=True)
75
+ raise typer.Exit(2) from None
76
+ if not attach_session(ctx.paths.root, run_id, session):
77
+ typer.echo("attach failed (unknown or archived run)", err=True)
78
+ raise typer.Exit(1)
79
+ typer.echo("attached")
80
+
81
+ @run_app.command("archive")
82
+ def archive(
83
+ run_id: Annotated[str, typer.Argument()],
84
+ path: Annotated[Path, typer.Option("--path")] = Path(),
85
+ ) -> None:
86
+ try:
87
+ ctx = resolve_repo(path.resolve())
88
+ except NotAGitRepo:
89
+ typer.echo("error: not a git repository", err=True)
90
+ raise typer.Exit(2) from None
91
+ record = load_run(ctx.paths.root, run_id)
92
+ if record is None:
93
+ typer.echo("run not found", err=True)
94
+ raise typer.Exit(1)
95
+ record.archived = True
96
+ save_run(ctx.paths.root, record)
97
+ typer.echo("archived")