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,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)