cortexshift 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 (100) hide show
  1. cortexshift/__init__.py +10 -0
  2. cortexshift/__main__.py +6 -0
  3. cortexshift/adapters/__init__.py +22 -0
  4. cortexshift/adapters/command_runner.py +116 -0
  5. cortexshift/adapters/discovery.py +55 -0
  6. cortexshift/adapters/git/__init__.py +10 -0
  7. cortexshift/adapters/git/inspector.py +321 -0
  8. cortexshift/adapters/git/parser.py +140 -0
  9. cortexshift/adapters/headless_runner.py +92 -0
  10. cortexshift/adapters/process_runner.py +56 -0
  11. cortexshift/adapters/providers/__init__.py +4 -0
  12. cortexshift/adapters/providers/antigravity.py +530 -0
  13. cortexshift/adapters/providers/claude.py +375 -0
  14. cortexshift/adapters/providers/codex.py +434 -0
  15. cortexshift/adapters/sqlite/__init__.py +10 -0
  16. cortexshift/adapters/sqlite/migrations.py +268 -0
  17. cortexshift/adapters/sqlite/store.py +914 -0
  18. cortexshift/adapters/workspace_lease.py +123 -0
  19. cortexshift/application/__init__.py +42 -0
  20. cortexshift/application/checkpoint_builder.py +218 -0
  21. cortexshift/application/checkpoint_service.py +273 -0
  22. cortexshift/application/doctor.py +80 -0
  23. cortexshift/application/handoff_builder.py +281 -0
  24. cortexshift/application/handoff_renderer.py +430 -0
  25. cortexshift/application/handoff_service.py +66 -0
  26. cortexshift/application/init_service.py +86 -0
  27. cortexshift/application/locator.py +48 -0
  28. cortexshift/application/native_session.py +65 -0
  29. cortexshift/application/recovery_service.py +235 -0
  30. cortexshift/application/repository_service.py +146 -0
  31. cortexshift/application/resume_service.py +124 -0
  32. cortexshift/application/run_service.py +270 -0
  33. cortexshift/application/session_launcher.py +183 -0
  34. cortexshift/application/session_service.py +63 -0
  35. cortexshift/application/source_session.py +62 -0
  36. cortexshift/application/status_service.py +73 -0
  37. cortexshift/application/switch_service.py +671 -0
  38. cortexshift/application/task_service.py +201 -0
  39. cortexshift/application/task_workspace.py +152 -0
  40. cortexshift/cli/__init__.py +5 -0
  41. cortexshift/cli/app.py +2477 -0
  42. cortexshift/domain/__init__.py +153 -0
  43. cortexshift/domain/checkpoint.py +174 -0
  44. cortexshift/domain/doctor.py +68 -0
  45. cortexshift/domain/errors.py +277 -0
  46. cortexshift/domain/git.py +102 -0
  47. cortexshift/domain/handoff.py +241 -0
  48. cortexshift/domain/identifiers.py +27 -0
  49. cortexshift/domain/launch.py +58 -0
  50. cortexshift/domain/mcp_binding.py +81 -0
  51. cortexshift/domain/native_session.py +19 -0
  52. cortexshift/domain/project.py +37 -0
  53. cortexshift/domain/provider.py +67 -0
  54. cortexshift/domain/session.py +92 -0
  55. cortexshift/domain/status.py +40 -0
  56. cortexshift/domain/task.py +191 -0
  57. cortexshift/mcp/__init__.py +38 -0
  58. cortexshift/mcp/context.py +165 -0
  59. cortexshift/mcp/facade.py +513 -0
  60. cortexshift/mcp/models.py +178 -0
  61. cortexshift/mcp/resources.py +45 -0
  62. cortexshift/mcp/server.py +52 -0
  63. cortexshift/mcp/tools.py +176 -0
  64. cortexshift/ports/__init__.py +39 -0
  65. cortexshift/ports/checkpoint_store.py +45 -0
  66. cortexshift/ports/command_runner.py +56 -0
  67. cortexshift/ports/discovery.py +41 -0
  68. cortexshift/ports/handoff_delivery.py +91 -0
  69. cortexshift/ports/handoff_store.py +43 -0
  70. cortexshift/ports/headless_runner.py +58 -0
  71. cortexshift/ports/native_session.py +20 -0
  72. cortexshift/ports/process_runner.py +31 -0
  73. cortexshift/ports/provider.py +152 -0
  74. cortexshift/ports/repository.py +44 -0
  75. cortexshift/ports/session_store.py +27 -0
  76. cortexshift/ports/state_store.py +55 -0
  77. cortexshift/ports/workspace_lease.py +39 -0
  78. cortexshift/tui/__init__.py +24 -0
  79. cortexshift/tui/actions.py +58 -0
  80. cortexshift/tui/app.py +1051 -0
  81. cortexshift/tui/coordinator.py +173 -0
  82. cortexshift/tui/cortexshift.tcss +258 -0
  83. cortexshift/tui/facade.py +614 -0
  84. cortexshift/tui/modals.py +594 -0
  85. cortexshift/tui/models.py +503 -0
  86. cortexshift/tui/screens/__init__.py +81 -0
  87. cortexshift/tui/screens/checkpoints.py +188 -0
  88. cortexshift/tui/screens/handoffs.py +180 -0
  89. cortexshift/tui/screens/help.py +117 -0
  90. cortexshift/tui/screens/overview.py +200 -0
  91. cortexshift/tui/screens/providers.py +169 -0
  92. cortexshift/tui/screens/repository.py +143 -0
  93. cortexshift/tui/screens/sessions.py +146 -0
  94. cortexshift/tui/screens/task.py +174 -0
  95. cortexshift/tui/widgets.py +209 -0
  96. cortexshift-0.1.0.dist-info/METADATA +202 -0
  97. cortexshift-0.1.0.dist-info/RECORD +100 -0
  98. cortexshift-0.1.0.dist-info/WHEEL +4 -0
  99. cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
  100. cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,123 @@
1
+ """File-based OS advisory lock implementation of the WorkspaceLease port."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from cortexshift.ports.workspace_lease import WorkspaceLease, WorkspaceLeaseManager
8
+
9
+ STATE_DIR_NAME = ".cortexshift"
10
+ LOCK_FILE_NAME = "agent.lock"
11
+
12
+
13
+ class FileWorkspaceLease(WorkspaceLease):
14
+ """OS-level advisory file lock enforcing single-mutating-agent invariant per project."""
15
+
16
+ def __init__(self, lock_path: Path) -> None:
17
+ self._lock_path = Path(lock_path).resolve()
18
+ self._fd: int | None = None
19
+
20
+ @property
21
+ def lock_path(self) -> Path:
22
+ return self._lock_path
23
+
24
+ def acquire(self) -> bool:
25
+ """Attempt non-blocking exclusive acquisition of the workspace lock."""
26
+ if self._fd is not None:
27
+ return True
28
+
29
+ self._lock_path.parent.mkdir(parents=True, exist_ok=True)
30
+ try:
31
+ fd = os.open(str(self._lock_path), os.O_RDWR | os.O_CREAT, 0o600)
32
+ except OSError:
33
+ return False
34
+
35
+ try:
36
+ if sys.platform == "win32":
37
+ import msvcrt
38
+
39
+ if os.path.getsize(str(self._lock_path)) == 0:
40
+ os.write(fd, b"\0")
41
+ os.lseek(fd, 0, os.SEEK_SET)
42
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
43
+ else:
44
+ import fcntl
45
+
46
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
47
+
48
+ self._fd = fd
49
+ return True
50
+ except (BlockingIOError, OSError):
51
+ os.close(fd)
52
+ return False
53
+
54
+ def release(self) -> None:
55
+ """Release the exclusive lock and close the underlying file descriptor."""
56
+ if self._fd is None:
57
+ return
58
+
59
+ try:
60
+ if sys.platform == "win32":
61
+ import msvcrt
62
+
63
+ os.lseek(self._fd, 0, os.SEEK_SET)
64
+ msvcrt.locking(self._fd, msvcrt.LK_UNLCK, 1)
65
+ else:
66
+ import fcntl
67
+
68
+ fcntl.flock(self._fd, fcntl.LOCK_UN)
69
+ except OSError:
70
+ pass
71
+ finally:
72
+ os.close(self._fd)
73
+ self._fd = None
74
+
75
+ def is_locked(self) -> bool:
76
+ """Check whether the lock is currently held by any process."""
77
+ if self._fd is not None:
78
+ return True
79
+ if not self._lock_path.exists():
80
+ return False
81
+ try:
82
+ fd = os.open(str(self._lock_path), os.O_RDWR, 0o600)
83
+ except OSError:
84
+ return False
85
+
86
+ try:
87
+ if sys.platform == "win32":
88
+ import msvcrt
89
+
90
+ if os.path.getsize(str(self._lock_path)) == 0:
91
+ return False
92
+ os.lseek(fd, 0, os.SEEK_SET)
93
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
94
+ os.lseek(fd, 0, os.SEEK_SET)
95
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
96
+ else:
97
+ import fcntl
98
+
99
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
100
+ fcntl.flock(fd, fcntl.LOCK_UN)
101
+ return False
102
+ except (BlockingIOError, OSError):
103
+ return True
104
+ finally:
105
+ os.close(fd)
106
+
107
+ def __enter__(self) -> "FileWorkspaceLease":
108
+ if not self.acquire():
109
+ from cortexshift.domain.errors import WorkspaceLockedError
110
+
111
+ raise WorkspaceLockedError(lock_path=self._lock_path)
112
+ return self
113
+
114
+ def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
115
+ self.release()
116
+
117
+
118
+ class FileWorkspaceLeaseManager(WorkspaceLeaseManager):
119
+ """Standard file-backed workspace lease manager."""
120
+
121
+ def get_lease(self, project_root: Path) -> WorkspaceLease:
122
+ lock_path = project_root / STATE_DIR_NAME / LOCK_FILE_NAME
123
+ return FileWorkspaceLease(lock_path)
@@ -0,0 +1,42 @@
1
+ """Application layer for CortexShift use cases and workflows.
2
+
3
+ Orchestrates domain entities, ports, and lifecycle policies.
4
+ """
5
+
6
+ from cortexshift.application.checkpoint_builder import CheckpointBuilder
7
+ from cortexshift.application.checkpoint_service import CheckpointService
8
+ from cortexshift.application.doctor import DoctorService, UnknownProviderError
9
+ from cortexshift.application.init_service import (
10
+ ProjectInitializationService,
11
+ ProjectInitResult,
12
+ )
13
+ from cortexshift.application.locator import ProjectLocator
14
+ from cortexshift.application.recovery_service import RecoveryReport, RecoveryService
15
+ from cortexshift.application.repository_service import RepositoryService
16
+ from cortexshift.application.run_service import (
17
+ DryRunResult,
18
+ ProviderRuntimeRegistry,
19
+ RunService,
20
+ )
21
+ from cortexshift.application.session_service import SessionService
22
+ from cortexshift.application.status_service import ProjectStatusService
23
+ from cortexshift.application.task_service import TaskService
24
+
25
+ __all__ = [
26
+ "CheckpointBuilder",
27
+ "CheckpointService",
28
+ "DoctorService",
29
+ "DryRunResult",
30
+ "ProjectInitializationService",
31
+ "ProjectInitResult",
32
+ "ProjectLocator",
33
+ "ProjectStatusService",
34
+ "ProviderRuntimeRegistry",
35
+ "RecoveryReport",
36
+ "RecoveryService",
37
+ "RepositoryService",
38
+ "RunService",
39
+ "SessionService",
40
+ "TaskService",
41
+ "UnknownProviderError",
42
+ ]
@@ -0,0 +1,218 @@
1
+ """Deterministic construction of canonical checkpoint records and payloads.
2
+
3
+ The builder is pure: it performs no I/O, invokes no provider, and consumes no model
4
+ quota. Its inputs are canonical Project, canonical Task, live repository inspection,
5
+ optional Session metadata, and optional enrichment (decisions, test summary, note).
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from cortexshift.application.handoff_builder import derive_files_touched
11
+ from cortexshift.domain.checkpoint import (
12
+ CHECKPOINT_PROTOCOL_VERSION,
13
+ MAX_DECISION_CHARS,
14
+ MAX_OPERATOR_NOTE_CHARS,
15
+ MAX_TEST_SUMMARY_CHARS,
16
+ CheckpointGitState,
17
+ CheckpointKind,
18
+ CheckpointPayload,
19
+ CheckpointRecord,
20
+ CheckpointSourceSession,
21
+ CheckpointTaskSnapshot,
22
+ CheckpointTestProvenance,
23
+ CheckpointTestStatus,
24
+ generate_checkpoint_id,
25
+ )
26
+ from cortexshift.domain.errors import InvalidCheckpointInputError
27
+ from cortexshift.domain.git import RepositoryInspection, RepositoryInspectionStatus
28
+ from cortexshift.domain.identifiers import utc_now
29
+ from cortexshift.domain.project import Project
30
+ from cortexshift.domain.session import Session
31
+ from cortexshift.domain.task import Task, TaskStatus
32
+
33
+ _GIT_NOTE_READY = (
34
+ "Live Git inspection succeeded at checkpoint time. "
35
+ "This is a historical observation, not current truth; re-check with `git status`."
36
+ )
37
+ _GIT_NOTE_NOT_INSTALLED = (
38
+ "Git was not found on this machine at checkpoint time. No repository state could be observed."
39
+ )
40
+ _GIT_NOTE_NOT_REPOSITORY = (
41
+ "This CortexShift project is not inside a Git repository. "
42
+ "No repository state could be observed."
43
+ )
44
+ _GIT_NOTE_PROBE_ERROR = (
45
+ "Git repository inspection failed at checkpoint time. "
46
+ "Treat any repository claim below as unverified and inspect the workspace directly."
47
+ )
48
+
49
+ _GIT_NOTES: dict[RepositoryInspectionStatus, str] = {
50
+ RepositoryInspectionStatus.READY: _GIT_NOTE_READY,
51
+ RepositoryInspectionStatus.GIT_NOT_INSTALLED: _GIT_NOTE_NOT_INSTALLED,
52
+ RepositoryInspectionStatus.NOT_GIT_REPOSITORY: _GIT_NOTE_NOT_REPOSITORY,
53
+ RepositoryInspectionStatus.PROBE_ERROR: _GIT_NOTE_PROBE_ERROR,
54
+ }
55
+
56
+
57
+ def build_checkpoint_git_state(
58
+ inspection: RepositoryInspection,
59
+ snapshot_id: str | None = None,
60
+ ) -> CheckpointGitState:
61
+ """Build the checkpoint GIT STATE section honestly encoding unavailable Git."""
62
+ note = _GIT_NOTES.get(inspection.status, _GIT_NOTE_PROBE_ERROR)
63
+ snapshot = inspection.snapshot
64
+
65
+ if inspection.status != RepositoryInspectionStatus.READY or snapshot is None:
66
+ return CheckpointGitState(status=inspection.status, available=False, note=note)
67
+
68
+ return CheckpointGitState(
69
+ status=inspection.status,
70
+ available=True,
71
+ note=note,
72
+ branch=snapshot.branch,
73
+ head_sha=snapshot.head_sha,
74
+ detached_head=snapshot.detached_head,
75
+ dirty=snapshot.dirty,
76
+ staged_count=len(snapshot.staged_files),
77
+ modified_count=len(snapshot.modified_files),
78
+ untracked_count=len(snapshot.untracked_files),
79
+ conflicted_count=len(snapshot.conflicted_files),
80
+ working_tree_diff_summary=snapshot.working_tree_diff_summary,
81
+ staged_diff_summary=snapshot.staged_diff_summary,
82
+ snapshot_id=snapshot_id,
83
+ )
84
+
85
+
86
+ class CheckpointBuilder:
87
+ """Constructs canonical CheckpointRecord and CheckpointPayload entities deterministically."""
88
+
89
+ @classmethod
90
+ def build(
91
+ cls,
92
+ project: Project,
93
+ task: Task,
94
+ inspection: RepositoryInspection,
95
+ kind: CheckpointKind,
96
+ snapshot_id: str | None = None,
97
+ session: Session | None = None,
98
+ decisions: list[str] | None = None,
99
+ test_summary: str | None = None,
100
+ test_provenance: CheckpointTestProvenance = CheckpointTestProvenance.UNKNOWN,
101
+ operator_note: str | None = None,
102
+ metadata: dict[str, Any] | None = None,
103
+ ) -> CheckpointRecord:
104
+ """Build a canonical CheckpointRecord from durable inputs.
105
+
106
+ Raises:
107
+ InvalidCheckpointInputError: If note, decisions, or test summary exceed bounds.
108
+ """
109
+ # Validate inputs
110
+ cleaned_note: str | None = None
111
+ if operator_note is not None:
112
+ cleaned_note = operator_note.strip()
113
+ if len(cleaned_note) > MAX_OPERATOR_NOTE_CHARS:
114
+ raise InvalidCheckpointInputError(
115
+ f"Operator note exceeds maximum allowed length of "
116
+ f"{MAX_OPERATOR_NOTE_CHARS} characters."
117
+ )
118
+ if not cleaned_note:
119
+ cleaned_note = None
120
+
121
+ cleaned_decisions: list[str] = []
122
+ if decisions is not None:
123
+ for decision in decisions:
124
+ d = decision.strip()
125
+ if d:
126
+ if len(d) > MAX_DECISION_CHARS:
127
+ raise InvalidCheckpointInputError(
128
+ f"Decision entry exceeds maximum allowed length of "
129
+ f"{MAX_DECISION_CHARS} characters."
130
+ )
131
+ cleaned_decisions.append(d)
132
+
133
+ cleaned_test_summary: str | None = None
134
+ if test_summary is not None:
135
+ cleaned_test_summary = test_summary.strip()
136
+ if len(cleaned_test_summary) > MAX_TEST_SUMMARY_CHARS:
137
+ raise InvalidCheckpointInputError(
138
+ f"Test summary exceeds maximum allowed length of "
139
+ f"{MAX_TEST_SUMMARY_CHARS} characters."
140
+ )
141
+ if not cleaned_test_summary:
142
+ cleaned_test_summary = None
143
+
144
+ # Build test status
145
+ if cleaned_test_summary is not None:
146
+ provenance = (
147
+ test_provenance
148
+ if test_provenance != CheckpointTestProvenance.UNKNOWN
149
+ else CheckpointTestProvenance.REPORTED
150
+ )
151
+ test_status = CheckpointTestStatus(
152
+ known=True,
153
+ summary=cleaned_test_summary,
154
+ provenance=provenance,
155
+ )
156
+ else:
157
+ test_status = CheckpointTestStatus(
158
+ known=False,
159
+ summary="No independently verified test results recorded.",
160
+ provenance=CheckpointTestProvenance.UNKNOWN,
161
+ )
162
+
163
+ # Build task snapshot
164
+ status_val = task.status.value if isinstance(task.status, TaskStatus) else str(task.status)
165
+ task_snapshot = CheckpointTaskSnapshot(
166
+ task_id=task.id,
167
+ task_title=task.title,
168
+ task_status=status_val,
169
+ objective=task.objective,
170
+ requirements=list(task.requirements),
171
+ constraints=list(task.constraints),
172
+ completed=list(task.completed_items),
173
+ current_work=task.current_work,
174
+ remaining=list(task.remaining_items),
175
+ known_issues=list(task.known_issues),
176
+ )
177
+
178
+ # Build source session summary
179
+ source_session_summary: CheckpointSourceSession | None = None
180
+ if session is not None:
181
+ source_session_summary = CheckpointSourceSession(
182
+ session_id=session.id,
183
+ provider_id=session.provider_id,
184
+ native_session_id=session.native_session_id,
185
+ status=session.status,
186
+ exit_reason=session.exit_reason,
187
+ exit_code=session.exit_code,
188
+ )
189
+
190
+ # Build Git state & files touched
191
+ git_state = build_checkpoint_git_state(inspection, snapshot_id=snapshot_id)
192
+ files_touched = derive_files_touched(inspection)
193
+
194
+ payload = CheckpointPayload(
195
+ protocol_version=CHECKPOINT_PROTOCOL_VERSION,
196
+ generated_at=utc_now(),
197
+ task=task_snapshot,
198
+ git_state=git_state,
199
+ files_touched=files_touched,
200
+ decisions=cleaned_decisions,
201
+ test_status=test_status,
202
+ operator_note=cleaned_note,
203
+ source_session=source_session_summary,
204
+ metadata=dict(metadata or {}),
205
+ )
206
+
207
+ return CheckpointRecord(
208
+ id=generate_checkpoint_id(),
209
+ protocol_version=CHECKPOINT_PROTOCOL_VERSION,
210
+ project_id=project.id,
211
+ task_id=task.id,
212
+ session_id=session.id if session else None,
213
+ git_snapshot_id=snapshot_id,
214
+ kind=kind,
215
+ payload=payload,
216
+ created_at=utc_now(),
217
+ metadata=dict(metadata or {}),
218
+ )
@@ -0,0 +1,273 @@
1
+ """Application service managing checkpoint creation, querying, and automatic session-end capture."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from cortexshift.adapters.git.inspector import GitRepositoryInspector
8
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
9
+ from cortexshift.application.checkpoint_builder import CheckpointBuilder
10
+ from cortexshift.application.locator import ProjectLocator
11
+ from cortexshift.domain.checkpoint import (
12
+ CheckpointKind,
13
+ CheckpointRecord,
14
+ CheckpointTestProvenance,
15
+ )
16
+ from cortexshift.domain.errors import (
17
+ CheckpointNotFoundError,
18
+ GitProbeError,
19
+ NoActiveTaskError,
20
+ ProjectNotInitializedError,
21
+ SessionNotFoundError,
22
+ SessionTaskMismatchError,
23
+ )
24
+ from cortexshift.domain.git import RepositoryInspectionStatus
25
+ from cortexshift.domain.project import Project
26
+ from cortexshift.domain.session import Session, SessionExitReason
27
+ from cortexshift.domain.task import Task
28
+ from cortexshift.ports.repository import RepositoryInspector
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class CheckpointService:
34
+ """Coordinates checkpoint operations across Project, Task, Repository, and Store."""
35
+
36
+ def __init__(
37
+ self,
38
+ inspector: RepositoryInspector | None = None,
39
+ ) -> None:
40
+ self._inspector = inspector or GitRepositoryInspector()
41
+
42
+ def _resolve_context(
43
+ self,
44
+ start_dir: Path | str | None = None,
45
+ ) -> tuple[Path, Project, Task, SQLiteStateStore]:
46
+ """Resolve project, active task, and state store."""
47
+ start_path = Path(start_dir) if start_dir is not None else None
48
+ project_root = ProjectLocator.find_project_root(start_path)
49
+ if project_root is None:
50
+ raise ProjectNotInitializedError()
51
+
52
+ db_path = ProjectLocator.get_database_path(project_root)
53
+ store = SQLiteStateStore(db_path, auto_migrate=False)
54
+
55
+ try:
56
+ project = store.get_default_project()
57
+ if project is None:
58
+ raise ProjectNotInitializedError()
59
+
60
+ active_task_id = store.get_active_task_id(project.id)
61
+ if not active_task_id:
62
+ raise NoActiveTaskError()
63
+
64
+ task = store.get_task(active_task_id)
65
+ if task is None:
66
+ raise NoActiveTaskError()
67
+
68
+ return project_root, project, task, store
69
+ except Exception:
70
+ store.close()
71
+ raise
72
+
73
+ def create_checkpoint(
74
+ self,
75
+ kind: CheckpointKind = CheckpointKind.MANUAL,
76
+ session_id: str | None = None,
77
+ decisions: list[str] | None = None,
78
+ test_summary: str | None = None,
79
+ test_provenance: CheckpointTestProvenance = CheckpointTestProvenance.REPORTED,
80
+ note: str | None = None,
81
+ start_dir: Path | str | None = None,
82
+ metadata: dict[str, Any] | None = None,
83
+ ) -> CheckpointRecord:
84
+ """Create and persist a canonical Checkpoint without requiring the workspace lease.
85
+
86
+ Manual checkpoints may be called from within a running provider session.
87
+ Inspection is non-atomic and advisory.
88
+ """
89
+ project_root, project, task, store = self._resolve_context(start_dir)
90
+ try:
91
+ # 1. Resolve session if requested or find latest for active task
92
+ session: Session | None = None
93
+ if session_id is not None:
94
+ session = store.get_session(session_id)
95
+ if session is None:
96
+ raise SessionNotFoundError(session_id)
97
+ if session.task_id != task.id:
98
+ raise SessionTaskMismatchError(session_id, task.id)
99
+ else:
100
+ sessions = store.list_sessions(task_id=task.id, limit=10)
101
+ # Prefer running or finished sessions over spawn-failed
102
+ for candidate in sessions:
103
+ if candidate.exit_reason != SessionExitReason.SPAWN_FAILED:
104
+ session = candidate
105
+ break
106
+ if session is None and sessions:
107
+ session = sessions[0]
108
+
109
+ # 2. Live Git inspection
110
+ inspection = self._inspector.inspect(
111
+ project_root=Path(project.repo_path),
112
+ project_id=project.id,
113
+ )
114
+
115
+ if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
116
+ raise GitProbeError(
117
+ inspection.diagnostic
118
+ or "Git repository inspection failed; refusing to create a checkpoint "
119
+ "from an unreliable repository observation."
120
+ )
121
+
122
+ snapshot_id: str | None = None
123
+ if (
124
+ inspection.status == RepositoryInspectionStatus.READY
125
+ and inspection.snapshot is not None
126
+ ):
127
+ store.save_snapshot(inspection.snapshot)
128
+ snapshot_id = inspection.snapshot.id
129
+
130
+ # 3. Build immutable checkpoint record
131
+ record = CheckpointBuilder.build(
132
+ project=project,
133
+ task=task,
134
+ inspection=inspection,
135
+ kind=kind,
136
+ snapshot_id=snapshot_id,
137
+ session=session,
138
+ decisions=decisions,
139
+ test_summary=test_summary,
140
+ test_provenance=test_provenance,
141
+ operator_note=note,
142
+ metadata=metadata,
143
+ )
144
+
145
+ # 4. Persist
146
+ store.save_checkpoint(record)
147
+ return record
148
+ finally:
149
+ store.close()
150
+
151
+ def get_checkpoint(
152
+ self,
153
+ checkpoint_id: str,
154
+ start_dir: Path | str | None = None,
155
+ ) -> CheckpointRecord:
156
+ """Retrieve a Checkpoint by its unique identifier."""
157
+ start_path = Path(start_dir) if start_dir is not None else None
158
+ project_root = ProjectLocator.find_project_root(start_path)
159
+ if project_root is None:
160
+ raise ProjectNotInitializedError()
161
+
162
+ db_path = ProjectLocator.get_database_path(project_root)
163
+ with SQLiteStateStore(db_path, auto_migrate=False) as store:
164
+ checkpoint = store.get_checkpoint(checkpoint_id)
165
+ if checkpoint is None:
166
+ raise CheckpointNotFoundError(checkpoint_id)
167
+ return checkpoint
168
+
169
+ def list_checkpoints(
170
+ self,
171
+ task_id: str | None = None,
172
+ limit: int | None = 20,
173
+ start_dir: Path | str | None = None,
174
+ ) -> list[CheckpointRecord]:
175
+ """List checkpoints for the active task or project, newest first."""
176
+ start_path = Path(start_dir) if start_dir is not None else None
177
+ project_root = ProjectLocator.find_project_root(start_path)
178
+ if project_root is None:
179
+ raise ProjectNotInitializedError()
180
+
181
+ db_path = ProjectLocator.get_database_path(project_root)
182
+ with SQLiteStateStore(db_path, auto_migrate=False) as store:
183
+ project = store.get_default_project()
184
+ if project is None:
185
+ raise ProjectNotInitializedError()
186
+
187
+ resolved_task_id = task_id
188
+ if resolved_task_id is None:
189
+ resolved_task_id = store.get_active_task_id(project.id)
190
+
191
+ return store.list_checkpoints(
192
+ project_id=project.id,
193
+ task_id=resolved_task_id,
194
+ limit=limit,
195
+ )
196
+
197
+ def get_latest_checkpoint(
198
+ self,
199
+ task_id: str | None = None,
200
+ start_dir: Path | str | None = None,
201
+ ) -> CheckpointRecord | None:
202
+ """Retrieve the newest checkpoint for the active task."""
203
+ results = self.list_checkpoints(task_id=task_id, limit=1, start_dir=start_dir)
204
+ return results[0] if results else None
205
+
206
+ def capture_session_end_checkpoint(
207
+ self,
208
+ session: Session,
209
+ store: SQLiteStateStore,
210
+ ) -> CheckpointRecord | None:
211
+ """Safely capture an automatic SESSION_END checkpoint when a provider process finishes.
212
+
213
+ This method never raises an exception out to the caller, preventing checkpoint
214
+ failures from rewriting or failing an otherwise completed coding session.
215
+ """
216
+ try:
217
+ task = store.get_task(session.task_id)
218
+ if task is None:
219
+ return None
220
+
221
+ project = store.get_project(task.project_id)
222
+ if project is None:
223
+ return None
224
+
225
+ inspection = self._inspector.inspect(
226
+ project_root=Path(project.repo_path),
227
+ project_id=project.id,
228
+ )
229
+
230
+ if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
231
+ # Safe warning: record capture warning in session metadata without rewriting status
232
+ logger.warning(
233
+ "Session-end checkpoint capture skipped: git probe error for session %s",
234
+ session.id,
235
+ )
236
+ updated_meta = dict(session.metadata)
237
+ updated_meta["checkpoint_capture_warning"] = "git_probe_error"
238
+ store.save_session(session.model_copy(update={"metadata": updated_meta}))
239
+ return None
240
+
241
+ snapshot_id: str | None = None
242
+ if (
243
+ inspection.status == RepositoryInspectionStatus.READY
244
+ and inspection.snapshot is not None
245
+ ):
246
+ store.save_snapshot(inspection.snapshot)
247
+ snapshot_id = inspection.snapshot.id
248
+
249
+ record = CheckpointBuilder.build(
250
+ project=project,
251
+ task=task,
252
+ inspection=inspection,
253
+ kind=CheckpointKind.SESSION_END,
254
+ snapshot_id=snapshot_id,
255
+ session=session,
256
+ test_provenance=CheckpointTestProvenance.UNKNOWN,
257
+ )
258
+
259
+ store.save_checkpoint(record)
260
+ return record
261
+ except Exception as exc:
262
+ logger.warning(
263
+ "Failed to capture session-end checkpoint for session %s: %s",
264
+ session.id,
265
+ exc,
266
+ )
267
+ try:
268
+ updated_meta = dict(session.metadata)
269
+ updated_meta["checkpoint_capture_warning"] = str(exc)
270
+ store.save_session(session.model_copy(update={"metadata": updated_meta}))
271
+ except Exception:
272
+ pass
273
+ return None