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.
- cortexshift/__init__.py +10 -0
- cortexshift/__main__.py +6 -0
- cortexshift/adapters/__init__.py +22 -0
- cortexshift/adapters/command_runner.py +116 -0
- cortexshift/adapters/discovery.py +55 -0
- cortexshift/adapters/git/__init__.py +10 -0
- cortexshift/adapters/git/inspector.py +321 -0
- cortexshift/adapters/git/parser.py +140 -0
- cortexshift/adapters/headless_runner.py +92 -0
- cortexshift/adapters/process_runner.py +56 -0
- cortexshift/adapters/providers/__init__.py +4 -0
- cortexshift/adapters/providers/antigravity.py +530 -0
- cortexshift/adapters/providers/claude.py +375 -0
- cortexshift/adapters/providers/codex.py +434 -0
- cortexshift/adapters/sqlite/__init__.py +10 -0
- cortexshift/adapters/sqlite/migrations.py +268 -0
- cortexshift/adapters/sqlite/store.py +914 -0
- cortexshift/adapters/workspace_lease.py +123 -0
- cortexshift/application/__init__.py +42 -0
- cortexshift/application/checkpoint_builder.py +218 -0
- cortexshift/application/checkpoint_service.py +273 -0
- cortexshift/application/doctor.py +80 -0
- cortexshift/application/handoff_builder.py +281 -0
- cortexshift/application/handoff_renderer.py +430 -0
- cortexshift/application/handoff_service.py +66 -0
- cortexshift/application/init_service.py +86 -0
- cortexshift/application/locator.py +48 -0
- cortexshift/application/native_session.py +65 -0
- cortexshift/application/recovery_service.py +235 -0
- cortexshift/application/repository_service.py +146 -0
- cortexshift/application/resume_service.py +124 -0
- cortexshift/application/run_service.py +270 -0
- cortexshift/application/session_launcher.py +183 -0
- cortexshift/application/session_service.py +63 -0
- cortexshift/application/source_session.py +62 -0
- cortexshift/application/status_service.py +73 -0
- cortexshift/application/switch_service.py +671 -0
- cortexshift/application/task_service.py +201 -0
- cortexshift/application/task_workspace.py +152 -0
- cortexshift/cli/__init__.py +5 -0
- cortexshift/cli/app.py +2477 -0
- cortexshift/domain/__init__.py +153 -0
- cortexshift/domain/checkpoint.py +174 -0
- cortexshift/domain/doctor.py +68 -0
- cortexshift/domain/errors.py +277 -0
- cortexshift/domain/git.py +102 -0
- cortexshift/domain/handoff.py +241 -0
- cortexshift/domain/identifiers.py +27 -0
- cortexshift/domain/launch.py +58 -0
- cortexshift/domain/mcp_binding.py +81 -0
- cortexshift/domain/native_session.py +19 -0
- cortexshift/domain/project.py +37 -0
- cortexshift/domain/provider.py +67 -0
- cortexshift/domain/session.py +92 -0
- cortexshift/domain/status.py +40 -0
- cortexshift/domain/task.py +191 -0
- cortexshift/mcp/__init__.py +38 -0
- cortexshift/mcp/context.py +165 -0
- cortexshift/mcp/facade.py +513 -0
- cortexshift/mcp/models.py +178 -0
- cortexshift/mcp/resources.py +45 -0
- cortexshift/mcp/server.py +52 -0
- cortexshift/mcp/tools.py +176 -0
- cortexshift/ports/__init__.py +39 -0
- cortexshift/ports/checkpoint_store.py +45 -0
- cortexshift/ports/command_runner.py +56 -0
- cortexshift/ports/discovery.py +41 -0
- cortexshift/ports/handoff_delivery.py +91 -0
- cortexshift/ports/handoff_store.py +43 -0
- cortexshift/ports/headless_runner.py +58 -0
- cortexshift/ports/native_session.py +20 -0
- cortexshift/ports/process_runner.py +31 -0
- cortexshift/ports/provider.py +152 -0
- cortexshift/ports/repository.py +44 -0
- cortexshift/ports/session_store.py +27 -0
- cortexshift/ports/state_store.py +55 -0
- cortexshift/ports/workspace_lease.py +39 -0
- cortexshift/tui/__init__.py +24 -0
- cortexshift/tui/actions.py +58 -0
- cortexshift/tui/app.py +1051 -0
- cortexshift/tui/coordinator.py +173 -0
- cortexshift/tui/cortexshift.tcss +258 -0
- cortexshift/tui/facade.py +614 -0
- cortexshift/tui/modals.py +594 -0
- cortexshift/tui/models.py +503 -0
- cortexshift/tui/screens/__init__.py +81 -0
- cortexshift/tui/screens/checkpoints.py +188 -0
- cortexshift/tui/screens/handoffs.py +180 -0
- cortexshift/tui/screens/help.py +117 -0
- cortexshift/tui/screens/overview.py +200 -0
- cortexshift/tui/screens/providers.py +169 -0
- cortexshift/tui/screens/repository.py +143 -0
- cortexshift/tui/screens/sessions.py +146 -0
- cortexshift/tui/screens/task.py +174 -0
- cortexshift/tui/widgets.py +209 -0
- cortexshift-0.1.0.dist-info/METADATA +202 -0
- cortexshift-0.1.0.dist-info/RECORD +100 -0
- cortexshift-0.1.0.dist-info/WHEEL +4 -0
- cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
- cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Domain model for Git repository snapshots and inspection results."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from cortexshift.domain.identifiers import generate_id, utc_now
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RepositoryInspectionStatus(StrEnum):
|
|
13
|
+
"""Status classification of a repository inspection."""
|
|
14
|
+
|
|
15
|
+
READY = "ready"
|
|
16
|
+
GIT_NOT_INSTALLED = "git_not_installed"
|
|
17
|
+
NOT_GIT_REPOSITORY = "not_git_repository"
|
|
18
|
+
PROBE_ERROR = "probe_error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitSnapshot(BaseModel):
|
|
22
|
+
"""Snapshot of a repository's Git working tree and commit state.
|
|
23
|
+
|
|
24
|
+
Represents an immutable, empirical version control observation at capture time.
|
|
25
|
+
A stored snapshot is evidence of what was true when captured, never proof of
|
|
26
|
+
current working tree reality after modifications occur.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(frozen=True)
|
|
30
|
+
|
|
31
|
+
id: str = Field(default_factory=lambda: generate_id("snap"))
|
|
32
|
+
project_id: str
|
|
33
|
+
project_root: str
|
|
34
|
+
git_root: str
|
|
35
|
+
git_version: str | None = None
|
|
36
|
+
branch: str | None = None
|
|
37
|
+
head_sha: str | None = None
|
|
38
|
+
detached_head: bool = False
|
|
39
|
+
dirty: bool = False
|
|
40
|
+
staged_files: list[str] = Field(default_factory=list)
|
|
41
|
+
modified_files: list[str] = Field(default_factory=list)
|
|
42
|
+
untracked_files: list[str] = Field(default_factory=list)
|
|
43
|
+
conflicted_files: list[str] = Field(default_factory=list)
|
|
44
|
+
working_tree_diff_summary: str | None = None
|
|
45
|
+
staged_diff_summary: str | None = None
|
|
46
|
+
captured_at: datetime = Field(default_factory=utc_now)
|
|
47
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@model_validator(mode="before")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _migrate_legacy_inputs(cls, data: Any) -> Any:
|
|
52
|
+
if isinstance(data, dict):
|
|
53
|
+
if "repo_path" in data:
|
|
54
|
+
data.setdefault("project_root", data["repo_path"])
|
|
55
|
+
data.setdefault("git_root", data["repo_path"])
|
|
56
|
+
if "is_dirty" in data:
|
|
57
|
+
data.setdefault("dirty", data["is_dirty"])
|
|
58
|
+
if "snapshot_at" in data:
|
|
59
|
+
data.setdefault("captured_at", data["snapshot_at"])
|
|
60
|
+
if "diff_summary" in data:
|
|
61
|
+
data.setdefault("working_tree_diff_summary", data["diff_summary"])
|
|
62
|
+
data.setdefault("project_id", "proj_default")
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def repo_path(self) -> str:
|
|
67
|
+
"""Backward-compatible alias for project_root."""
|
|
68
|
+
return self.project_root
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_dirty(self) -> bool:
|
|
72
|
+
"""Backward-compatible alias for dirty."""
|
|
73
|
+
return self.dirty
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def snapshot_at(self) -> datetime:
|
|
77
|
+
"""Backward-compatible alias for captured_at."""
|
|
78
|
+
return self.captured_at
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def diff_summary(self) -> str | None:
|
|
82
|
+
"""Backward-compatible alias for working_tree_diff_summary."""
|
|
83
|
+
return self.working_tree_diff_summary
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RepositoryInspection(BaseModel):
|
|
87
|
+
"""Result of a live repository inspection."""
|
|
88
|
+
|
|
89
|
+
model_config = ConfigDict(frozen=True)
|
|
90
|
+
|
|
91
|
+
status: RepositoryInspectionStatus
|
|
92
|
+
project_root: str
|
|
93
|
+
git_available: bool
|
|
94
|
+
git_version: str | None = None
|
|
95
|
+
snapshot: GitSnapshot | None = None
|
|
96
|
+
diagnostic: str | None = None
|
|
97
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def is_ready(self) -> bool:
|
|
101
|
+
"""Return True if the repository was successfully inspected and ready."""
|
|
102
|
+
return self.status == RepositoryInspectionStatus.READY and self.snapshot is not None
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Domain models for the canonical, provider-independent CortexShift handoff protocol.
|
|
2
|
+
|
|
3
|
+
A handoff is an immutable point-in-time context package that allows a CortexShift Task
|
|
4
|
+
to move from one coding agent to another without requiring the outgoing agent to still
|
|
5
|
+
be available. Two concepts are modelled explicitly:
|
|
6
|
+
|
|
7
|
+
- ``HandoffPayload``: the canonical point-in-time engineering context (protocol v1).
|
|
8
|
+
- ``HandoffRecord``: orchestration and delivery metadata wrapping a payload.
|
|
9
|
+
|
|
10
|
+
The payload is deliberately provider-neutral. Provider-specific transport (how the
|
|
11
|
+
context reaches a given CLI) lives behind handoff delivery adapters, and rendered
|
|
12
|
+
provider prompts are never persisted.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
from cortexshift.domain.git import RepositoryInspectionStatus
|
|
22
|
+
from cortexshift.domain.identifiers import generate_id, utc_now
|
|
23
|
+
from cortexshift.domain.provider import ProviderId
|
|
24
|
+
from cortexshift.domain.session import SessionExitReason, SessionStatus
|
|
25
|
+
|
|
26
|
+
# Version of the canonical handoff contract. Deliberately independent from the
|
|
27
|
+
# SQLite schema version: handoff formatting and canonical fields may evolve
|
|
28
|
+
# without a database migration, and vice versa.
|
|
29
|
+
HANDOFF_PROTOCOL_VERSION = 1
|
|
30
|
+
|
|
31
|
+
# Maximum number of characters retained for an operator-supplied note. The note is
|
|
32
|
+
# optional advisory context and must never make the canonical payload unbounded.
|
|
33
|
+
MAX_OPERATOR_NOTE_CHARS = 2_000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HandoffStatus(StrEnum):
|
|
37
|
+
"""Delivery lifecycle of a handoff record.
|
|
38
|
+
|
|
39
|
+
- PREPARED: canonical handoff exists; target delivery is not yet known complete.
|
|
40
|
+
- DELIVERED: handoff context was successfully delivered via the provider strategy.
|
|
41
|
+
- FAILED: delivery or provider bootstrap could not be completed.
|
|
42
|
+
|
|
43
|
+
Handoff status is distinct from Session status: the target Session remains the
|
|
44
|
+
source of truth for how the receiving coding session itself eventually ended.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
PREPARED = "prepared"
|
|
48
|
+
DELIVERED = "delivered"
|
|
49
|
+
FAILED = "failed"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class HandoffFailureCode(StrEnum):
|
|
53
|
+
"""Safe machine classification of a handoff delivery failure.
|
|
54
|
+
|
|
55
|
+
Failure codes never embed raw provider stdout/stderr, credentials, or prompts.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
TARGET_PROVIDER_MISSING = "target_provider_missing"
|
|
59
|
+
BOOTSTRAP_FAILED = "bootstrap_failed"
|
|
60
|
+
BOOTSTRAP_TIMEOUT = "bootstrap_timeout"
|
|
61
|
+
BOOTSTRAP_INVALID_OUTPUT = "bootstrap_invalid_output"
|
|
62
|
+
SPAWN_FAILED = "spawn_failed"
|
|
63
|
+
WORKSPACE_LOCKED = "workspace_locked"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class HandoffGitState(BaseModel):
|
|
67
|
+
"""Canonical Git-state section of a handoff payload.
|
|
68
|
+
|
|
69
|
+
CortexShift projects do not require Git. When Git is unavailable or the project is
|
|
70
|
+
not a repository, this records an explicit, honest marker rather than fabricating
|
|
71
|
+
repository facts. When Git is ready, ``snapshot_id`` references the immutable
|
|
72
|
+
``GitSnapshot`` persisted at handoff time — a historical observation, never proof
|
|
73
|
+
of current working tree reality.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(frozen=True)
|
|
77
|
+
|
|
78
|
+
status: RepositoryInspectionStatus
|
|
79
|
+
available: bool = False
|
|
80
|
+
note: str
|
|
81
|
+
branch: str | None = None
|
|
82
|
+
head_sha: str | None = None
|
|
83
|
+
detached_head: bool = False
|
|
84
|
+
dirty: bool = False
|
|
85
|
+
staged_count: int = 0
|
|
86
|
+
modified_count: int = 0
|
|
87
|
+
untracked_count: int = 0
|
|
88
|
+
conflicted_count: int = 0
|
|
89
|
+
working_tree_diff_summary: str | None = None
|
|
90
|
+
staged_diff_summary: str | None = None
|
|
91
|
+
snapshot_id: str | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class HandoffTestStatus(BaseModel):
|
|
95
|
+
"""Canonical TEST STATUS section.
|
|
96
|
+
|
|
97
|
+
CortexShift has no durable verified test record in Phase 5. A provider process
|
|
98
|
+
exiting with code 0 does not prove that project tests passed, so ``known`` stays
|
|
99
|
+
False and the summary states the unknown honestly.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(frozen=True)
|
|
103
|
+
|
|
104
|
+
known: bool = False
|
|
105
|
+
summary: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class HandoffSourceSession(BaseModel):
|
|
109
|
+
"""Metadata of the CortexShift Session the work is being handed off from.
|
|
110
|
+
|
|
111
|
+
Derived strictly from durable CortexShift session records. Provider-native
|
|
112
|
+
transcripts, conversation histories, and hidden reasoning are never read.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(frozen=True)
|
|
116
|
+
|
|
117
|
+
session_id: str
|
|
118
|
+
provider_id: ProviderId
|
|
119
|
+
status: SessionStatus
|
|
120
|
+
started_at: datetime
|
|
121
|
+
ended_at: datetime | None = None
|
|
122
|
+
exit_reason: SessionExitReason | None = None
|
|
123
|
+
exit_code: int | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class HandoffPayload(BaseModel):
|
|
127
|
+
"""Canonical, provider-independent point-in-time engineering context.
|
|
128
|
+
|
|
129
|
+
Built deterministically from durable local state (canonical Project, canonical Task,
|
|
130
|
+
previous CortexShift Session metadata, live repository inspection, and the Git
|
|
131
|
+
snapshot persisted at handoff time). Generating this payload never requires the
|
|
132
|
+
outgoing provider to be installed, running, or able to answer.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
model_config = ConfigDict(frozen=True)
|
|
136
|
+
|
|
137
|
+
protocol_version: int = HANDOFF_PROTOCOL_VERSION
|
|
138
|
+
generated_at: datetime = Field(default_factory=utc_now)
|
|
139
|
+
|
|
140
|
+
# PROJECT
|
|
141
|
+
project_name: str
|
|
142
|
+
project_root: str
|
|
143
|
+
|
|
144
|
+
# Task identity
|
|
145
|
+
task_id: str
|
|
146
|
+
task_title: str
|
|
147
|
+
task_status: str
|
|
148
|
+
|
|
149
|
+
# Canonical protocol sections
|
|
150
|
+
original_objective: str
|
|
151
|
+
requirements: list[str] = Field(default_factory=list)
|
|
152
|
+
constraints: list[str] = Field(default_factory=list)
|
|
153
|
+
completed: list[str] = Field(default_factory=list)
|
|
154
|
+
current_work: str | None = None
|
|
155
|
+
remaining: list[str] = Field(default_factory=list)
|
|
156
|
+
important_decisions: list[str] = Field(default_factory=list)
|
|
157
|
+
decisions_known: bool = False
|
|
158
|
+
files_touched: list[str] = Field(default_factory=list)
|
|
159
|
+
test_status: HandoffTestStatus
|
|
160
|
+
known_issues: list[str] = Field(default_factory=list)
|
|
161
|
+
git_state: HandoffGitState
|
|
162
|
+
do_not_redo: list[str] = Field(default_factory=list)
|
|
163
|
+
recommended_next_action: str
|
|
164
|
+
|
|
165
|
+
# Provenance
|
|
166
|
+
source_session: HandoffSourceSession
|
|
167
|
+
target_provider_id: ProviderId
|
|
168
|
+
operator_note: str | None = None
|
|
169
|
+
source_checkpoint_id: str | None = None
|
|
170
|
+
source_checkpoint_kind: str | None = None
|
|
171
|
+
source_checkpoint_created_at: datetime | None = None
|
|
172
|
+
|
|
173
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def generate_handoff_id() -> str:
|
|
177
|
+
"""Generate default ID for handoff records."""
|
|
178
|
+
return generate_id("handoff")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class HandoffRecord(BaseModel):
|
|
182
|
+
"""Orchestration and delivery metadata wrapping a canonical handoff payload.
|
|
183
|
+
|
|
184
|
+
The record tracks which Task moved between which CortexShift Sessions and providers,
|
|
185
|
+
which Git snapshot was captured, and whether delivery ultimately succeeded. Rendered
|
|
186
|
+
provider prompts and provider responses are deliberately not part of this record.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
model_config = ConfigDict(frozen=True)
|
|
190
|
+
|
|
191
|
+
id: str = Field(default_factory=generate_handoff_id)
|
|
192
|
+
protocol_version: int = HANDOFF_PROTOCOL_VERSION
|
|
193
|
+
|
|
194
|
+
project_id: str
|
|
195
|
+
task_id: str
|
|
196
|
+
|
|
197
|
+
source_session_id: str
|
|
198
|
+
source_provider_id: ProviderId
|
|
199
|
+
target_provider_id: ProviderId
|
|
200
|
+
|
|
201
|
+
source_checkpoint_id: str | None = None
|
|
202
|
+
git_snapshot_id: str | None = None
|
|
203
|
+
target_session_id: str | None = None
|
|
204
|
+
|
|
205
|
+
status: HandoffStatus = HandoffStatus.PREPARED
|
|
206
|
+
payload: HandoffPayload
|
|
207
|
+
|
|
208
|
+
created_at: datetime = Field(default_factory=utc_now)
|
|
209
|
+
delivered_at: datetime | None = None
|
|
210
|
+
failure_code: HandoffFailureCode | None = None
|
|
211
|
+
|
|
212
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
213
|
+
|
|
214
|
+
def mark_delivered(
|
|
215
|
+
self,
|
|
216
|
+
target_session_id: str | None = None,
|
|
217
|
+
delivered_at: datetime | None = None,
|
|
218
|
+
) -> "HandoffRecord":
|
|
219
|
+
"""Return a copy marked as delivered, optionally binding the target Session."""
|
|
220
|
+
return self.model_copy(
|
|
221
|
+
update={
|
|
222
|
+
"status": HandoffStatus.DELIVERED,
|
|
223
|
+
"delivered_at": delivered_at or utc_now(),
|
|
224
|
+
"target_session_id": target_session_id or self.target_session_id,
|
|
225
|
+
"failure_code": None,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def mark_failed(
|
|
230
|
+
self,
|
|
231
|
+
failure_code: HandoffFailureCode,
|
|
232
|
+
target_session_id: str | None = None,
|
|
233
|
+
) -> "HandoffRecord":
|
|
234
|
+
"""Return a copy marked as failed with a safe machine classification."""
|
|
235
|
+
return self.model_copy(
|
|
236
|
+
update={
|
|
237
|
+
"status": HandoffStatus.FAILED,
|
|
238
|
+
"failure_code": failure_code,
|
|
239
|
+
"target_session_id": target_session_id or self.target_session_id,
|
|
240
|
+
}
|
|
241
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Stable identifier generation and timezone-aware timestamp utilities."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_id(prefix: str) -> str:
|
|
8
|
+
"""Generate a collision-resistant, human-readable prefixed identifier.
|
|
9
|
+
|
|
10
|
+
Format: `<prefix>_<uuid4_hex>` (e.g., `proj_1234567890abcdef...`).
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
prefix: Short entity prefix (e.g., 'proj', 'task', 'sess', 'cp', 'handoff').
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
A unique, stable identifier string.
|
|
17
|
+
"""
|
|
18
|
+
clean_prefix = prefix.strip().lower()
|
|
19
|
+
return f"{clean_prefix}_{uuid.uuid4().hex}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def utc_now() -> datetime:
|
|
23
|
+
"""Return the current timezone-aware UTC datetime.
|
|
24
|
+
|
|
25
|
+
Avoids naive datetimes across all domain and persistence boundaries.
|
|
26
|
+
"""
|
|
27
|
+
return datetime.now(UTC)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Domain model representing the launch specification for a native provider process."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from cortexshift.domain.provider import ProviderId
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LaunchSpecification(BaseModel):
|
|
12
|
+
"""Immutable specification for launching a native provider process.
|
|
13
|
+
|
|
14
|
+
Contains the exact argument list, working directory, and metadata needed
|
|
15
|
+
to spawn an interactive or headless provider session.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(frozen=True)
|
|
19
|
+
|
|
20
|
+
provider_id: ProviderId
|
|
21
|
+
executable: str
|
|
22
|
+
cwd: Path
|
|
23
|
+
argv: list[str]
|
|
24
|
+
native_session_id: str | None = None
|
|
25
|
+
interactive: bool = True
|
|
26
|
+
initial_prompt_supported: bool = True
|
|
27
|
+
prompt_supplied: bool = False
|
|
28
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
29
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def to_redacted_argv(self) -> list[str]:
|
|
32
|
+
"""Return the argument list with any raw prompt replaced with '<prompt>'.
|
|
33
|
+
|
|
34
|
+
Preserves the executable and option flags while redacting prompt text.
|
|
35
|
+
"""
|
|
36
|
+
if not self.prompt_supplied:
|
|
37
|
+
return list(self.argv)
|
|
38
|
+
|
|
39
|
+
# For providers where prompt is passed as the last positional argument
|
|
40
|
+
redacted = list(self.argv)
|
|
41
|
+
if len(redacted) > 1:
|
|
42
|
+
redacted[-1] = "<prompt>"
|
|
43
|
+
return redacted
|
|
44
|
+
|
|
45
|
+
def to_redacted_dict(self) -> dict[str, Any]:
|
|
46
|
+
"""Return a dictionary representation suitable for logs and dry-run diagnostics.
|
|
47
|
+
|
|
48
|
+
Guarantees that raw user prompt contents are never leaked.
|
|
49
|
+
"""
|
|
50
|
+
return {
|
|
51
|
+
"provider_id": str(self.provider_id),
|
|
52
|
+
"executable": self.executable,
|
|
53
|
+
"cwd": str(self.cwd),
|
|
54
|
+
"argv": self.to_redacted_argv(),
|
|
55
|
+
"interactive": self.interactive,
|
|
56
|
+
"initial_prompt_supported": self.initial_prompt_supported,
|
|
57
|
+
"prompt_supplied": self.prompt_supplied,
|
|
58
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Trusted managed-session binding handed to a provider-spawned MCP server.
|
|
2
|
+
|
|
3
|
+
A provider CLI, not CortexShift, spawns the CortexShift MCP server. The binding below is
|
|
4
|
+
the only channel through which CortexShift's own launch path states which project, task,
|
|
5
|
+
CortexShift session, provider, and execution mode that server is bound to. It is minted
|
|
6
|
+
exclusively from persisted state at launch time and is never assembled from provider or
|
|
7
|
+
model input.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
13
|
+
|
|
14
|
+
from cortexshift.domain.provider import ProviderId
|
|
15
|
+
from cortexshift.domain.session import Session
|
|
16
|
+
|
|
17
|
+
ENV_PROJECT_ROOT = "CORTEXSHIFT_PROJECT_ROOT"
|
|
18
|
+
ENV_TASK_ID = "CORTEXSHIFT_TASK_ID"
|
|
19
|
+
ENV_SESSION_ID = "CORTEXSHIFT_SESSION_ID"
|
|
20
|
+
ENV_PROVIDER_ID = "CORTEXSHIFT_PROVIDER_ID"
|
|
21
|
+
ENV_MCP_READ_ONLY = "CORTEXSHIFT_MCP_READ_ONLY"
|
|
22
|
+
|
|
23
|
+
#: Every variable the managed binding owns. A launch specification may add environment of
|
|
24
|
+
#: its own, but never these: the binding is the single authority over execution context.
|
|
25
|
+
BINDING_ENV_VARS = (
|
|
26
|
+
ENV_PROJECT_ROOT,
|
|
27
|
+
ENV_TASK_ID,
|
|
28
|
+
ENV_SESSION_ID,
|
|
29
|
+
ENV_PROVIDER_ID,
|
|
30
|
+
ENV_MCP_READ_ONLY,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class McpSessionBinding(BaseModel):
|
|
35
|
+
"""Canonical managed execution context for one provider launch.
|
|
36
|
+
|
|
37
|
+
The MCP server re-validates every field against SQLite before granting write tools, so
|
|
38
|
+
this object asserts an identity rather than a permission.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(frozen=True)
|
|
42
|
+
|
|
43
|
+
project_root: Path
|
|
44
|
+
task_id: str
|
|
45
|
+
session_id: str
|
|
46
|
+
provider_id: ProviderId
|
|
47
|
+
read_only: bool = False
|
|
48
|
+
|
|
49
|
+
@field_validator("task_id", "session_id")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _reject_blank(cls, value: str) -> str:
|
|
52
|
+
cleaned = value.strip()
|
|
53
|
+
if not cleaned:
|
|
54
|
+
raise ValueError("Managed MCP binding identifiers cannot be blank.")
|
|
55
|
+
return cleaned
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_session(
|
|
59
|
+
cls,
|
|
60
|
+
session: Session,
|
|
61
|
+
project_root: Path,
|
|
62
|
+
read_only: bool = False,
|
|
63
|
+
) -> "McpSessionBinding":
|
|
64
|
+
"""Mint a binding from a persisted Session, the only trusted source of identity."""
|
|
65
|
+
return cls(
|
|
66
|
+
project_root=project_root,
|
|
67
|
+
task_id=session.task_id,
|
|
68
|
+
session_id=session.id,
|
|
69
|
+
provider_id=session.provider_id,
|
|
70
|
+
read_only=read_only,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def to_env(self) -> dict[str, str]:
|
|
74
|
+
"""Render the binding as the environment variables the MCP server resolves."""
|
|
75
|
+
return {
|
|
76
|
+
ENV_PROJECT_ROOT: str(self.project_root),
|
|
77
|
+
ENV_TASK_ID: self.task_id,
|
|
78
|
+
ENV_SESSION_ID: self.session_id,
|
|
79
|
+
ENV_PROVIDER_ID: str(self.provider_id),
|
|
80
|
+
ENV_MCP_READ_ONLY: "1" if self.read_only else "0",
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Provider-native identity capabilities, independent of orchestration history."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class NativeSessionCapabilities:
|
|
9
|
+
supports_exact_resume: bool = False
|
|
10
|
+
can_allocate_native_id_before_launch: bool = False
|
|
11
|
+
can_capture_native_id_during_bootstrap: bool = False
|
|
12
|
+
can_resume_with_followup_context: bool = False
|
|
13
|
+
requires_model_turn_for_handoff_resume: bool = False
|
|
14
|
+
supports_managed_new_session: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def valid_native_id(value: str | None) -> bool:
|
|
18
|
+
"""Reject empty IDs, option injection and control characters; IDs remain opaque."""
|
|
19
|
+
return bool(value and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,255}", value))
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Domain model for a CortexShift Project."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
7
|
+
|
|
8
|
+
from cortexshift.domain.identifiers import generate_id, utc_now
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_project_id() -> str:
|
|
12
|
+
"""Generate default ID for projects."""
|
|
13
|
+
return generate_id("proj")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Project(BaseModel):
|
|
17
|
+
"""Represents a software project and repository managed by CortexShift.
|
|
18
|
+
|
|
19
|
+
Projects outlive individual tasks and contain long-lived memory such as
|
|
20
|
+
architecture invariants, conventions, and repository location.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(frozen=True)
|
|
24
|
+
|
|
25
|
+
id: str = Field(default_factory=generate_project_id)
|
|
26
|
+
name: str
|
|
27
|
+
repo_path: str
|
|
28
|
+
created_at: datetime = Field(default_factory=utc_now)
|
|
29
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
@field_validator("name")
|
|
32
|
+
@classmethod
|
|
33
|
+
def validate_name_not_empty(cls, v: str) -> str:
|
|
34
|
+
stripped = v.strip()
|
|
35
|
+
if not stripped:
|
|
36
|
+
raise ValueError("Project name cannot be empty or blank.")
|
|
37
|
+
return stripped
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Domain models for extensible provider identification and capabilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProviderId(str):
|
|
10
|
+
"""Extensible provider identifier.
|
|
11
|
+
|
|
12
|
+
Unlike a closed enum, ProviderId accepts any valid identifier matching
|
|
13
|
+
`^[a-z0-9][a-z0-9_-]{0,63}$`. This enables future agents (e.g., 'claude',
|
|
14
|
+
'codex', 'antigravity', 'kimi', 'aider') without core domain changes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
18
|
+
|
|
19
|
+
def __new__(cls, value: str) -> "ProviderId":
|
|
20
|
+
cleaned = value.strip().lower()
|
|
21
|
+
if not cls._PATTERN.match(cleaned):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Invalid provider ID '{value}'. Must match pattern: ^[a-z0-9][a-z0-9_-]{{0,63}}$"
|
|
24
|
+
)
|
|
25
|
+
return super().__new__(cls, cleaned)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> Any:
|
|
29
|
+
from pydantic_core import core_schema
|
|
30
|
+
|
|
31
|
+
def validate(v: Any) -> "ProviderId":
|
|
32
|
+
if isinstance(v, ProviderId):
|
|
33
|
+
return v
|
|
34
|
+
if isinstance(v, str):
|
|
35
|
+
return ProviderId(v)
|
|
36
|
+
raise ValueError(f"Expected string or ProviderId, got {type(v)}")
|
|
37
|
+
|
|
38
|
+
return core_schema.no_info_after_validator_function(
|
|
39
|
+
validate,
|
|
40
|
+
core_schema.str_schema(),
|
|
41
|
+
serialization=core_schema.to_string_ser_schema(),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Standard canonical provider constants for common reference
|
|
46
|
+
PROVIDER_CLAUDE = ProviderId("claude")
|
|
47
|
+
PROVIDER_CODEX = ProviderId("codex")
|
|
48
|
+
PROVIDER_ANTIGRAVITY = ProviderId("antigravity")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProviderCapabilities(BaseModel):
|
|
52
|
+
"""Declared capabilities of a coding agent provider.
|
|
53
|
+
|
|
54
|
+
Defines what operational modes and features an agent CLI supports.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
model_config = ConfigDict(frozen=True)
|
|
58
|
+
|
|
59
|
+
provider_id: ProviderId
|
|
60
|
+
display_name: str
|
|
61
|
+
supports_interactive: bool = True
|
|
62
|
+
supports_headless: bool = False
|
|
63
|
+
supports_native_resume: bool = False
|
|
64
|
+
supports_structured_output: bool = False
|
|
65
|
+
supports_mcp: bool = False
|
|
66
|
+
supports_usage_metrics: bool = False
|
|
67
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|