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,92 @@
1
+ """Domain model for a coding agent execution session."""
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from cortexshift.domain.identifiers import generate_id, utc_now
10
+ from cortexshift.domain.provider import ProviderId
11
+
12
+
13
+ class SessionStatus(StrEnum):
14
+ """Execution status of an individual agent session.
15
+
16
+ Phase 4 Active Statuses:
17
+ - INITIALIZING: Session record created before process launch.
18
+ - RUNNING: Session process is actively executing.
19
+ - COMPLETED: Process exited normally with exit code 0.
20
+ - INTERRUPTED: Session was interrupted by user (SIGINT/Ctrl+C, exit code 130).
21
+ - FAILED: Session process exited with non-zero exit code or spawn failed.
22
+
23
+ Reserved Future Statuses (not emitted in Phase 4):
24
+ - ACTIVE: Reserved alias for active execution states.
25
+ - TIMED_OUT: Reserved for execution timeout policies.
26
+ """
27
+
28
+ INITIALIZING = "initializing"
29
+ RUNNING = "running"
30
+ ACTIVE = "active"
31
+ COMPLETED = "completed"
32
+ INTERRUPTED = "interrupted"
33
+ FAILED = "failed"
34
+ TIMED_OUT = "timed_out"
35
+
36
+
37
+ class SessionExitReason(StrEnum):
38
+ """Reason why an agent session concluded or halted.
39
+
40
+ Phase 4 Active Exit Reasons:
41
+ - NORMAL_COMPLETION: Native CLI process exited with code 0.
42
+ - USER_INTERRUPTED: User interrupted interactive session via SIGINT (Ctrl+C).
43
+ - PROCESS_CRASHED: Native CLI process exited with non-zero exit code.
44
+ - SPAWN_FAILED: Process runner failed to spawn the native CLI executable.
45
+
46
+ Reserved Future Exit Reasons (not emitted in Phase 4):
47
+ - QUOTA_EXHAUSTED: Reserved for future structured API quota exhaustion detection.
48
+ - RATE_LIMITED: Reserved for future provider rate limit detection.
49
+ - UNEXPECTED_TERMINATION: Reserved for unexpected external process termination.
50
+ - UNKNOWN: Reserved fallback for unclassifiable exits.
51
+
52
+ Note: Phase 4 does not infer quota exhaustion or rate limits from generic
53
+ non-zero exit codes. Generic non-zero process exits map strictly to PROCESS_CRASHED.
54
+ """
55
+
56
+ NORMAL_COMPLETION = "normal_completion"
57
+ USER_INTERRUPTED = "user_interrupted"
58
+ PROCESS_CRASHED = "process_crashed"
59
+ SPAWN_FAILED = "spawn_failed"
60
+
61
+ # Reserved for future phases (not emitted in Phase 4):
62
+ QUOTA_EXHAUSTED = "quota_exhausted"
63
+ RATE_LIMITED = "rate_limited"
64
+ UNEXPECTED_TERMINATION = "unexpected_termination"
65
+ UNKNOWN = "unknown"
66
+
67
+
68
+ def generate_session_id() -> str:
69
+ """Generate default ID for sessions."""
70
+ return generate_id("sess")
71
+
72
+
73
+ class Session(BaseModel):
74
+ """An execution session by a specific coding agent working on a task.
75
+
76
+ Sessions are transient worker executions; tasks outlast sessions.
77
+ """
78
+
79
+ model_config = ConfigDict(frozen=True)
80
+
81
+ id: str = Field(default_factory=generate_session_id)
82
+ task_id: str
83
+ provider_id: ProviderId
84
+ native_session_id: str | None = None
85
+ resumed_from_session_id: str | None = None
86
+ status: SessionStatus = SessionStatus.INITIALIZING
87
+ started_at: datetime = Field(default_factory=utc_now)
88
+ ended_at: datetime | None = None
89
+ exit_reason: SessionExitReason | None = None
90
+ exit_code: int | None = None
91
+ reconciled_at: datetime | None = None
92
+ metadata: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,40 @@
1
+ """Domain models representing project and task status."""
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from cortexshift.domain.task import TaskStatus
6
+
7
+
8
+ class ProgressSummary(BaseModel):
9
+ """Summary counts of task progression items."""
10
+
11
+ model_config = ConfigDict(frozen=True)
12
+
13
+ completed: int = 0
14
+ remaining: int = 0
15
+ issues: int = 0
16
+
17
+
18
+ class ActiveTaskSummary(BaseModel):
19
+ """Summary of the currently active task on a project."""
20
+
21
+ model_config = ConfigDict(frozen=True)
22
+
23
+ id: str
24
+ title: str
25
+ status: TaskStatus
26
+ objective: str
27
+ progress: ProgressSummary
28
+
29
+
30
+ class ProjectStatus(BaseModel):
31
+ """Comprehensive status of an initialized CortexShift project."""
32
+
33
+ model_config = ConfigDict(frozen=True)
34
+
35
+ project_id: str
36
+ name: str
37
+ repo_path: str
38
+ state_file: str
39
+ schema_version: int
40
+ active_task: ActiveTaskSummary | None = None
@@ -0,0 +1,191 @@
1
+ """Domain model for a CortexShift Task."""
2
+
3
+ from datetime import datetime
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
8
+
9
+ from cortexshift.domain.identifiers import generate_id, utc_now
10
+
11
+ # Canonical input bounds for task progress mutations. Shared by every adapter that
12
+ # accepts operator or agent supplied task text (MCP tools, the TUI control center).
13
+ MAX_CURRENT_WORK_CHARS = 2000
14
+ MAX_ITEM_CHARS = 500
15
+ MAX_ITEMS_PER_CALL = 50
16
+
17
+
18
+ class TaskStatus(StrEnum):
19
+ """Lifecycle status of a persistent development task."""
20
+
21
+ PENDING = "pending"
22
+ IN_PROGRESS = "in_progress"
23
+ BLOCKED = "blocked"
24
+ COMPLETED = "completed"
25
+ CANCELLED = "cancelled"
26
+
27
+
28
+ def generate_task_id() -> str:
29
+ """Generate default ID for tasks."""
30
+ return generate_id("task")
31
+
32
+
33
+ class Task(BaseModel):
34
+ """Represents a persistent development task.
35
+
36
+ The task is the central abstraction of CortexShift. Coding agents are temporary
37
+ workers operating sequentially on this task.
38
+ """
39
+
40
+ model_config = ConfigDict(frozen=True)
41
+
42
+ id: str = Field(default_factory=generate_task_id)
43
+ project_id: str
44
+ title: str
45
+ objective: str
46
+ requirements: list[str] = Field(default_factory=list)
47
+ constraints: list[str] = Field(default_factory=list)
48
+ status: TaskStatus = TaskStatus.PENDING
49
+ completed_items: list[str] = Field(default_factory=list)
50
+ current_work: str | None = None
51
+ remaining_items: list[str] = Field(default_factory=list)
52
+ known_issues: list[str] = Field(default_factory=list)
53
+ created_at: datetime = Field(default_factory=utc_now)
54
+ updated_at: datetime = Field(default_factory=utc_now)
55
+ metadata: dict[str, Any] = Field(default_factory=dict)
56
+
57
+ @model_validator(mode="before")
58
+ @classmethod
59
+ def _normalize_completed_and_remaining(cls, data: Any) -> Any:
60
+ if isinstance(data, dict):
61
+ # Support both completed and completed_items
62
+ if "completed" in data and "completed_items" not in data:
63
+ data["completed_items"] = data.pop("completed")
64
+ # Support both remaining and remaining_items
65
+ if "remaining" in data and "remaining_items" not in data:
66
+ data["remaining_items"] = data.pop("remaining")
67
+ return data
68
+
69
+ @field_validator("title", "objective")
70
+ @classmethod
71
+ def validate_non_empty_strings(cls, v: str) -> str:
72
+ stripped = v.strip()
73
+ if not stripped:
74
+ raise ValueError("Field cannot be empty or blank.")
75
+ return stripped
76
+
77
+ @computed_field # type: ignore[prop-decorator]
78
+ @property
79
+ def completed(self) -> list[str]:
80
+ """Canonical alias for completed_items."""
81
+ return self.completed_items
82
+
83
+ @computed_field # type: ignore[prop-decorator]
84
+ @property
85
+ def remaining(self) -> list[str]:
86
+ """Canonical alias for remaining_items."""
87
+ return self.remaining_items
88
+
89
+ @property
90
+ def is_terminal(self) -> bool:
91
+ """Return True if the task is in a terminal state (cannot be resumed/activated)."""
92
+ return self.status in (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
93
+
94
+ def mark_completed(self) -> "Task":
95
+ """Return a copy of this task marked as completed with updated timestamp."""
96
+ now = utc_now()
97
+ return self.model_copy(
98
+ update={
99
+ "status": TaskStatus.COMPLETED,
100
+ "updated_at": now,
101
+ }
102
+ )
103
+
104
+ def with_current_work(self, work: str | None) -> "Task":
105
+ """Return a copy of this task with updated current work and timestamp."""
106
+ now = utc_now()
107
+ cleaned = work.strip() if work is not None else None
108
+ return self.model_copy(
109
+ update={
110
+ "current_work": cleaned if cleaned else None,
111
+ "updated_at": now,
112
+ }
113
+ )
114
+
115
+ def add_completed_items(self, items: list[str]) -> "Task":
116
+ """Return a copy of this task with additional completed items (deduplicated)."""
117
+ new_items = list(self.completed_items)
118
+ existing_set = set(new_items)
119
+ for item in items:
120
+ cleaned = item.strip()
121
+ if cleaned and cleaned not in existing_set:
122
+ new_items.append(cleaned)
123
+ existing_set.add(cleaned)
124
+ return self.model_copy(
125
+ update={
126
+ "completed_items": new_items,
127
+ "updated_at": utc_now(),
128
+ }
129
+ )
130
+
131
+ def add_remaining_items(self, items: list[str]) -> "Task":
132
+ """Return a copy of this task with additional remaining items (deduplicated)."""
133
+ new_items = list(self.remaining_items)
134
+ existing_set = set(new_items)
135
+ for item in items:
136
+ cleaned = item.strip()
137
+ if cleaned and cleaned not in existing_set:
138
+ new_items.append(cleaned)
139
+ existing_set.add(cleaned)
140
+ return self.model_copy(
141
+ update={
142
+ "remaining_items": new_items,
143
+ "updated_at": utc_now(),
144
+ }
145
+ )
146
+
147
+ def add_known_issues(self, items: list[str]) -> "Task":
148
+ """Return a copy of this task with additional known issues (deduplicated)."""
149
+ new_items = list(self.known_issues)
150
+ existing_set = set(new_items)
151
+ for item in items:
152
+ cleaned = item.strip()
153
+ if cleaned and cleaned not in existing_set:
154
+ new_items.append(cleaned)
155
+ existing_set.add(cleaned)
156
+ return self.model_copy(
157
+ update={
158
+ "known_issues": new_items,
159
+ "updated_at": utc_now(),
160
+ }
161
+ )
162
+
163
+ def complete_items(self, items: list[str]) -> "Task":
164
+ """Return a copy with items marked completed and dropped from remaining.
165
+
166
+ This is the canonical "mark completed" rule: items are appended to
167
+ `completed_items` (deduplicated) and any exactly matching entry is removed from
168
+ `remaining_items`. It never completes the Task itself — see `mark_completed`.
169
+ """
170
+ cleaned = [stripped for item in items if (stripped := item.strip())]
171
+ if not cleaned:
172
+ return self
173
+
174
+ updated = self.add_completed_items(cleaned)
175
+ completed_set = set(cleaned)
176
+ return updated.model_copy(
177
+ update={
178
+ "remaining_items": [
179
+ item for item in updated.remaining_items if item not in completed_set
180
+ ],
181
+ "updated_at": utc_now(),
182
+ }
183
+ )
184
+
185
+ def add_completed(self, items: list[str]) -> "Task":
186
+ """Alias for add_completed_items."""
187
+ return self.add_completed_items(items)
188
+
189
+ def add_remaining(self, items: list[str]) -> "Task":
190
+ """Alias for add_remaining_items."""
191
+ return self.add_remaining_items(items)
@@ -0,0 +1,38 @@
1
+ """CortexShift Model Context Protocol (MCP) shared state integration."""
2
+
3
+ from cortexshift.mcp.context import (
4
+ ENV_MCP_READ_ONLY,
5
+ ENV_PROJECT_ROOT,
6
+ ENV_PROVIDER_ID,
7
+ ENV_SESSION_ID,
8
+ ENV_TASK_ID,
9
+ McpExecutionContext,
10
+ resolve_mcp_context,
11
+ )
12
+ from cortexshift.mcp.facade import McpApplicationFacade
13
+ from cortexshift.mcp.models import (
14
+ CheckpointResult,
15
+ CreateCheckpointResult,
16
+ DecisionResult,
17
+ ProjectContextResult,
18
+ TaskMutationResult,
19
+ )
20
+ from cortexshift.mcp.server import create_mcp_server, run_mcp_server
21
+
22
+ __all__ = [
23
+ "ENV_MCP_READ_ONLY",
24
+ "ENV_PROJECT_ROOT",
25
+ "ENV_PROVIDER_ID",
26
+ "ENV_SESSION_ID",
27
+ "ENV_TASK_ID",
28
+ "CheckpointResult",
29
+ "CreateCheckpointResult",
30
+ "DecisionResult",
31
+ "McpApplicationFacade",
32
+ "McpExecutionContext",
33
+ "ProjectContextResult",
34
+ "TaskMutationResult",
35
+ "create_mcp_server",
36
+ "resolve_mcp_context",
37
+ "run_mcp_server",
38
+ ]
@@ -0,0 +1,165 @@
1
+ """Execution context resolution and validation for CortexShift MCP server."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
9
+ from cortexshift.application.locator import ProjectLocator
10
+ from cortexshift.domain.errors import McpContextError
11
+ from cortexshift.domain.mcp_binding import (
12
+ ENV_MCP_READ_ONLY,
13
+ ENV_PROJECT_ROOT,
14
+ ENV_PROVIDER_ID,
15
+ ENV_SESSION_ID,
16
+ ENV_TASK_ID,
17
+ )
18
+ from cortexshift.domain.provider import ProviderId
19
+
20
+ __all__ = [
21
+ "ENV_MCP_READ_ONLY",
22
+ "ENV_PROJECT_ROOT",
23
+ "ENV_PROVIDER_ID",
24
+ "ENV_SESSION_ID",
25
+ "ENV_TASK_ID",
26
+ "McpExecutionContext",
27
+ "resolve_mcp_context",
28
+ ]
29
+
30
+
31
+ class McpExecutionContext(BaseModel):
32
+ """Canonical execution context binding the MCP server to local project state."""
33
+
34
+ model_config = ConfigDict(frozen=True)
35
+
36
+ project_root: Path
37
+ project_id: str
38
+ task_id: str | None = None
39
+ session_id: str | None = None
40
+ provider_id: ProviderId | None = None
41
+ managed_session: bool = False
42
+ read_only: bool = False
43
+
44
+ def can_mutate(self) -> bool:
45
+ """Return True if write operations are permitted in this context."""
46
+ return self.managed_session and not self.read_only
47
+
48
+
49
+ def resolve_mcp_context(
50
+ project_root_override: Path | None = None,
51
+ store_override: SQLiteStateStore | None = None,
52
+ env_override: dict[str, str] | None = None,
53
+ ) -> tuple[McpExecutionContext, SQLiteStateStore]:
54
+ """Resolve and validate the canonical McpExecutionContext.
55
+
56
+ Validates:
57
+ - Project initialization and root resolution
58
+ - Session existence, task ownership, and provider identity (if session_id provided)
59
+ - Bound task existence and project membership
60
+ - Read-only enforcement for unmanaged invocations or explicit read-only flag
61
+
62
+ Returns:
63
+ A tuple of (McpExecutionContext, initialized SQLiteStateStore).
64
+
65
+ Raises:
66
+ McpContextError: If the context violates invariants or SQLite state is invalid.
67
+ """
68
+ env = os.environ if env_override is None else env_override
69
+
70
+ # 1. Resolve project root
71
+ explicit_root_str = env.get(ENV_PROJECT_ROOT)
72
+ candidate_root: Path | None = None
73
+ if project_root_override is not None:
74
+ candidate_root = project_root_override.resolve()
75
+ elif explicit_root_str:
76
+ candidate_root = Path(explicit_root_str).resolve()
77
+ else:
78
+ candidate_root = ProjectLocator.find_project_root()
79
+
80
+ if candidate_root is None or not ProjectLocator.is_initialized(candidate_root):
81
+ loc = candidate_root or Path.cwd()
82
+ raise McpContextError(f"No initialized CortexShift project found at or above '{loc}'.")
83
+
84
+ project_root = candidate_root.resolve()
85
+
86
+ # 2. Connect store and fetch project
87
+ store = (
88
+ store_override
89
+ if store_override is not None
90
+ else SQLiteStateStore(ProjectLocator.get_database_path(project_root))
91
+ )
92
+
93
+ project = store.get_default_project()
94
+ if project is None:
95
+ raise McpContextError(f"No project record found in database at {project_root}.")
96
+
97
+ if Path(project.repo_path).resolve() != project_root:
98
+ raise McpContextError(
99
+ f"Project root mismatch: record specifies '{project.repo_path}', "
100
+ f"but current root is '{project_root}'."
101
+ )
102
+
103
+ # 3. Read-only override
104
+ explicit_read_only = env.get(ENV_MCP_READ_ONLY, "0").strip().lower() in ("1", "true", "yes")
105
+
106
+ session_id = env.get(ENV_SESSION_ID)
107
+ env_task_id = env.get(ENV_TASK_ID)
108
+ env_provider_id = env.get(ENV_PROVIDER_ID)
109
+
110
+ if session_id:
111
+ # Managed invocation: must be strictly validated
112
+ session = store.get_session(session_id)
113
+ if session is None:
114
+ raise McpContextError(f"Session '{session_id}' not found in project '{project.id}'.")
115
+
116
+ if env_task_id and session.task_id != env_task_id:
117
+ raise McpContextError(
118
+ f"Session '{session_id}' task '{session.task_id}' does not match "
119
+ f"environment task '{env_task_id}'."
120
+ )
121
+
122
+ if env_provider_id and str(session.provider_id) != env_provider_id:
123
+ raise McpContextError(
124
+ f"Session '{session_id}' provider '{session.provider_id}' does not match "
125
+ f"environment provider '{env_provider_id}'."
126
+ )
127
+
128
+ task = store.get_task(session.task_id)
129
+ if task is None:
130
+ raise McpContextError(
131
+ f"Task '{session.task_id}' bound to session '{session_id}' not found."
132
+ )
133
+
134
+ if task.project_id != project.id:
135
+ raise McpContextError(f"Task '{task.id}' does not belong to project '{project.id}'.")
136
+
137
+ context = McpExecutionContext(
138
+ project_root=project_root,
139
+ project_id=project.id,
140
+ task_id=session.task_id,
141
+ session_id=session.id,
142
+ provider_id=session.provider_id,
143
+ managed_session=True,
144
+ read_only=explicit_read_only,
145
+ )
146
+ else:
147
+ # Unmanaged invocation: strictly read-only
148
+ candidate_task_id = env_task_id or store.get_active_task_id(project.id)
149
+ resolved_task_id: str | None = None
150
+ if candidate_task_id:
151
+ task = store.get_task(candidate_task_id)
152
+ if task and task.project_id == project.id:
153
+ resolved_task_id = task.id
154
+
155
+ context = McpExecutionContext(
156
+ project_root=project_root,
157
+ project_id=project.id,
158
+ task_id=resolved_task_id,
159
+ session_id=None,
160
+ provider_id=None,
161
+ managed_session=False,
162
+ read_only=True,
163
+ )
164
+
165
+ return context, store