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,513 @@
|
|
|
1
|
+
"""Application facade interfacing existing domain and application services for MCP."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from cortexshift.adapters.git.inspector import GitRepositoryInspector
|
|
6
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
7
|
+
from cortexshift.application.checkpoint_builder import CheckpointBuilder
|
|
8
|
+
from cortexshift.application.repository_service import RepositoryService
|
|
9
|
+
from cortexshift.domain.checkpoint import (
|
|
10
|
+
CHECKPOINT_TRIGGER_DECISION,
|
|
11
|
+
CHECKPOINT_TRIGGER_KEY,
|
|
12
|
+
CheckpointKind,
|
|
13
|
+
CheckpointRecord,
|
|
14
|
+
CheckpointTestProvenance,
|
|
15
|
+
)
|
|
16
|
+
from cortexshift.domain.errors import (
|
|
17
|
+
McpReadOnlyError,
|
|
18
|
+
NoActiveTaskError,
|
|
19
|
+
TaskNotFoundError,
|
|
20
|
+
)
|
|
21
|
+
from cortexshift.domain.git import RepositoryInspectionStatus
|
|
22
|
+
from cortexshift.domain.identifiers import utc_now
|
|
23
|
+
from cortexshift.domain.task import Task
|
|
24
|
+
from cortexshift.mcp.context import McpExecutionContext
|
|
25
|
+
from cortexshift.mcp.models import (
|
|
26
|
+
MAX_CHANGED_PATHS_BUDGET,
|
|
27
|
+
MAX_CURRENT_WORK_CHARS,
|
|
28
|
+
MAX_DECISION_CHARS,
|
|
29
|
+
MAX_ITEM_CHARS,
|
|
30
|
+
MAX_ITEMS_PER_CALL,
|
|
31
|
+
MAX_NOTE_CHARS,
|
|
32
|
+
MAX_TEST_SUMMARY_CHARS,
|
|
33
|
+
CheckpointResult,
|
|
34
|
+
CheckpointSummary,
|
|
35
|
+
CreateCheckpointResult,
|
|
36
|
+
DecisionResult,
|
|
37
|
+
ProjectContextResult,
|
|
38
|
+
ProjectSummary,
|
|
39
|
+
RepositoryStatusSummary,
|
|
40
|
+
SessionSummary,
|
|
41
|
+
TaskMutationResult,
|
|
42
|
+
TaskSummary,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class McpApplicationFacade:
|
|
47
|
+
"""Coordinates read and write operations requested through MCP.
|
|
48
|
+
|
|
49
|
+
Guarantees:
|
|
50
|
+
- Never executes direct SQL outside SQLiteStateStore.
|
|
51
|
+
- Operates strictly on the session-bound Task.
|
|
52
|
+
- Bypasses the exclusive workspace lease (.cortexshift/agent.lock) for state reporting.
|
|
53
|
+
- Enforces read-only mode for unmanaged invocations.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
context: McpExecutionContext,
|
|
59
|
+
store: SQLiteStateStore,
|
|
60
|
+
repository_service: RepositoryService | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._context = context
|
|
63
|
+
self._store = store
|
|
64
|
+
self._repo_service = repository_service or RepositoryService(
|
|
65
|
+
inspector=GitRepositoryInspector(),
|
|
66
|
+
store=store,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _ensure_can_mutate(self) -> None:
|
|
70
|
+
if not self._context.can_mutate():
|
|
71
|
+
raise McpReadOnlyError(
|
|
72
|
+
"State mutation is not permitted in read-only or unmanaged MCP mode."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _get_bound_task(self) -> Task:
|
|
76
|
+
if not self._context.task_id:
|
|
77
|
+
raise NoActiveTaskError("No bound task found for this MCP session.")
|
|
78
|
+
task = self._store.get_task(self._context.task_id)
|
|
79
|
+
if task is None:
|
|
80
|
+
raise TaskNotFoundError(self._context.task_id)
|
|
81
|
+
return task
|
|
82
|
+
|
|
83
|
+
# -------------------------------------------------------------------------
|
|
84
|
+
# Read Operations
|
|
85
|
+
# -------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def get_project_context(self) -> ProjectContextResult:
|
|
88
|
+
"""Return structured, bounded context for the bound project and task."""
|
|
89
|
+
project = self._store.get_project(self._context.project_id)
|
|
90
|
+
proj_name = project.name if project else self._context.project_root.name
|
|
91
|
+
active_tid = self._store.get_active_task_id(self._context.project_id)
|
|
92
|
+
|
|
93
|
+
proj_summary = ProjectSummary(
|
|
94
|
+
id=self._context.project_id,
|
|
95
|
+
name=proj_name,
|
|
96
|
+
root=str(self._context.project_root),
|
|
97
|
+
active_task_id=active_tid,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
task_summary: TaskSummary | None = None
|
|
101
|
+
if self._context.task_id:
|
|
102
|
+
task = self._store.get_task(self._context.task_id)
|
|
103
|
+
if task:
|
|
104
|
+
task_summary = TaskSummary(
|
|
105
|
+
id=task.id,
|
|
106
|
+
title=task.title,
|
|
107
|
+
status=task.status.value,
|
|
108
|
+
objective=task.objective,
|
|
109
|
+
current_work=task.current_work,
|
|
110
|
+
completed_count=len(task.completed),
|
|
111
|
+
remaining_count=len(task.remaining),
|
|
112
|
+
known_issues_count=len(task.known_issues),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
session_summary: SessionSummary | None = None
|
|
116
|
+
if self._context.session_id:
|
|
117
|
+
session = self._store.get_session(self._context.session_id)
|
|
118
|
+
if session:
|
|
119
|
+
session_summary = SessionSummary(
|
|
120
|
+
id=session.id,
|
|
121
|
+
provider_id=str(session.provider_id),
|
|
122
|
+
status=session.status.value,
|
|
123
|
+
started_at=session.started_at.isoformat(),
|
|
124
|
+
native_session_id=session.native_session_id,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
checkpoint_summary: CheckpointSummary | None = None
|
|
128
|
+
if self._context.task_id:
|
|
129
|
+
cp = self._store.get_latest_checkpoint(self._context.task_id)
|
|
130
|
+
if cp:
|
|
131
|
+
test_summary = (
|
|
132
|
+
cp.payload.test_status.summary if cp.payload.test_status.known else None
|
|
133
|
+
)
|
|
134
|
+
test_prov = (
|
|
135
|
+
cp.payload.test_status.provenance.value
|
|
136
|
+
if cp.payload.test_status.known
|
|
137
|
+
else None
|
|
138
|
+
)
|
|
139
|
+
checkpoint_summary = CheckpointSummary(
|
|
140
|
+
id=cp.id,
|
|
141
|
+
kind=cp.kind.value,
|
|
142
|
+
created_at=cp.created_at.isoformat(),
|
|
143
|
+
decisions=list(cp.payload.decisions),
|
|
144
|
+
test_summary=test_summary,
|
|
145
|
+
test_provenance=test_prov,
|
|
146
|
+
note=cp.payload.operator_note,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Live repository inspection (bounded paths)
|
|
150
|
+
repo_summary: RepositoryStatusSummary | None = None
|
|
151
|
+
inspection = self._repo_service.inspect_repository(self._context.project_root)
|
|
152
|
+
if inspection.snapshot:
|
|
153
|
+
snap = inspection.snapshot
|
|
154
|
+
all_changed = [
|
|
155
|
+
*snap.staged_files,
|
|
156
|
+
*snap.modified_files,
|
|
157
|
+
*snap.untracked_files,
|
|
158
|
+
*snap.conflicted_files,
|
|
159
|
+
]
|
|
160
|
+
# Deduplicate changed paths
|
|
161
|
+
seen: set[str] = set()
|
|
162
|
+
dedup_changed: list[str] = []
|
|
163
|
+
for p in all_changed:
|
|
164
|
+
if p not in seen:
|
|
165
|
+
seen.add(p)
|
|
166
|
+
dedup_changed.append(p)
|
|
167
|
+
|
|
168
|
+
budgeted_paths = dedup_changed[:MAX_CHANGED_PATHS_BUDGET]
|
|
169
|
+
omitted = max(0, len(dedup_changed) - MAX_CHANGED_PATHS_BUDGET)
|
|
170
|
+
|
|
171
|
+
repo_summary = RepositoryStatusSummary(
|
|
172
|
+
branch=snap.branch,
|
|
173
|
+
head_commit=snap.head_sha,
|
|
174
|
+
dirty=snap.dirty,
|
|
175
|
+
staged_count=len(snap.staged_files),
|
|
176
|
+
modified_count=len(snap.modified_files),
|
|
177
|
+
untracked_count=len(snap.untracked_files),
|
|
178
|
+
conflicted_count=len(snap.conflicted_files),
|
|
179
|
+
diff_shortstat=snap.working_tree_diff_summary or snap.staged_diff_summary,
|
|
180
|
+
changed_paths=budgeted_paths,
|
|
181
|
+
omitted_paths_count=omitted,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return ProjectContextResult(
|
|
185
|
+
project=proj_summary,
|
|
186
|
+
task=task_summary,
|
|
187
|
+
session=session_summary,
|
|
188
|
+
latest_checkpoint=checkpoint_summary,
|
|
189
|
+
repository=repo_summary,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def get_current_task(self) -> dict[str, Any]:
|
|
193
|
+
"""Return the complete canonical bound Task."""
|
|
194
|
+
task = self._get_bound_task()
|
|
195
|
+
return task.model_dump(mode="json")
|
|
196
|
+
|
|
197
|
+
def get_latest_checkpoint(self) -> CheckpointResult:
|
|
198
|
+
"""Return the latest checkpoint for the bound task, or null."""
|
|
199
|
+
if not self._context.task_id:
|
|
200
|
+
return CheckpointResult(checkpoint=None)
|
|
201
|
+
cp = self._store.get_latest_checkpoint(self._context.task_id)
|
|
202
|
+
if cp is None:
|
|
203
|
+
return CheckpointResult(checkpoint=None)
|
|
204
|
+
return CheckpointResult(checkpoint=cp.model_dump(mode="json"))
|
|
205
|
+
|
|
206
|
+
def get_repository_status(self) -> dict[str, Any]:
|
|
207
|
+
"""Perform live, read-only repository inspection."""
|
|
208
|
+
inspection = self._repo_service.inspect_repository(self._context.project_root)
|
|
209
|
+
if inspection.snapshot is None:
|
|
210
|
+
return {
|
|
211
|
+
"available": False,
|
|
212
|
+
"status": inspection.status.value,
|
|
213
|
+
"diagnostic": inspection.diagnostic,
|
|
214
|
+
}
|
|
215
|
+
snap = inspection.snapshot
|
|
216
|
+
return {
|
|
217
|
+
"available": True,
|
|
218
|
+
"status": inspection.status.value,
|
|
219
|
+
"branch": snap.branch,
|
|
220
|
+
"head_sha": snap.head_sha,
|
|
221
|
+
"dirty": snap.dirty,
|
|
222
|
+
"staged": list(snap.staged_files),
|
|
223
|
+
"modified": list(snap.modified_files),
|
|
224
|
+
"untracked": list(snap.untracked_files),
|
|
225
|
+
"conflicted": list(snap.conflicted_files),
|
|
226
|
+
"diff_shortstat": snap.working_tree_diff_summary or snap.staged_diff_summary,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# -------------------------------------------------------------------------
|
|
230
|
+
# Write Operations
|
|
231
|
+
# -------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def set_current_work(self, current_work: str | None) -> TaskMutationResult:
|
|
234
|
+
"""Update Task.current_work atomically."""
|
|
235
|
+
self._ensure_can_mutate()
|
|
236
|
+
if current_work is not None and len(current_work) > MAX_CURRENT_WORK_CHARS:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"current_work exceeds maximum length of {MAX_CURRENT_WORK_CHARS} characters."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
clean_work: str | None = (
|
|
242
|
+
current_work.strip() if current_work and current_work.strip() else None
|
|
243
|
+
)
|
|
244
|
+
task = self._get_bound_task()
|
|
245
|
+
updated = task.model_copy(
|
|
246
|
+
update={
|
|
247
|
+
"current_work": clean_work,
|
|
248
|
+
"updated_at": utc_now(),
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
self._store.save_task(updated)
|
|
252
|
+
|
|
253
|
+
return TaskMutationResult(
|
|
254
|
+
task_id=updated.id,
|
|
255
|
+
updated_at=updated.updated_at.isoformat(),
|
|
256
|
+
completed=list(updated.completed),
|
|
257
|
+
remaining=list(updated.remaining),
|
|
258
|
+
current_work=updated.current_work,
|
|
259
|
+
known_issues=list(updated.known_issues),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def mark_completed(self, items: list[str]) -> TaskMutationResult:
|
|
263
|
+
"""Add items to completed and remove matching entries from remaining."""
|
|
264
|
+
self._ensure_can_mutate()
|
|
265
|
+
if not items:
|
|
266
|
+
raise ValueError("items cannot be empty.")
|
|
267
|
+
if len(items) > MAX_ITEMS_PER_CALL:
|
|
268
|
+
raise ValueError(f"Too many items (maximum {MAX_ITEMS_PER_CALL}).")
|
|
269
|
+
|
|
270
|
+
cleaned: list[str] = []
|
|
271
|
+
for it in items:
|
|
272
|
+
s = it.strip()
|
|
273
|
+
if not s:
|
|
274
|
+
continue
|
|
275
|
+
if len(s) > MAX_ITEM_CHARS:
|
|
276
|
+
raise ValueError(f"Item exceeds maximum length of {MAX_ITEM_CHARS} characters.")
|
|
277
|
+
cleaned.append(s)
|
|
278
|
+
|
|
279
|
+
if not cleaned:
|
|
280
|
+
raise ValueError("No non-empty items provided.")
|
|
281
|
+
|
|
282
|
+
task = self._get_bound_task()
|
|
283
|
+
updated = task.complete_items(cleaned)
|
|
284
|
+
self._store.save_task(updated)
|
|
285
|
+
|
|
286
|
+
return TaskMutationResult(
|
|
287
|
+
task_id=updated.id,
|
|
288
|
+
updated_at=updated.updated_at.isoformat(),
|
|
289
|
+
completed=list(updated.completed),
|
|
290
|
+
remaining=list(updated.remaining),
|
|
291
|
+
current_work=updated.current_work,
|
|
292
|
+
known_issues=list(updated.known_issues),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def add_remaining(self, items: list[str]) -> TaskMutationResult:
|
|
296
|
+
"""Append deduplicated items to Task.remaining."""
|
|
297
|
+
self._ensure_can_mutate()
|
|
298
|
+
if not items:
|
|
299
|
+
raise ValueError("items cannot be empty.")
|
|
300
|
+
if len(items) > MAX_ITEMS_PER_CALL:
|
|
301
|
+
raise ValueError(f"Too many items (maximum {MAX_ITEMS_PER_CALL}).")
|
|
302
|
+
|
|
303
|
+
cleaned: list[str] = []
|
|
304
|
+
for it in items:
|
|
305
|
+
s = it.strip()
|
|
306
|
+
if not s:
|
|
307
|
+
continue
|
|
308
|
+
if len(s) > MAX_ITEM_CHARS:
|
|
309
|
+
raise ValueError(f"Item exceeds maximum length of {MAX_ITEM_CHARS} characters.")
|
|
310
|
+
cleaned.append(s)
|
|
311
|
+
|
|
312
|
+
if not cleaned:
|
|
313
|
+
raise ValueError("No non-empty items provided.")
|
|
314
|
+
|
|
315
|
+
task = self._get_bound_task()
|
|
316
|
+
|
|
317
|
+
new_remaining = list(task.remaining)
|
|
318
|
+
for it in cleaned:
|
|
319
|
+
if it not in new_remaining:
|
|
320
|
+
new_remaining.append(it)
|
|
321
|
+
|
|
322
|
+
updated = task.model_copy(
|
|
323
|
+
update={
|
|
324
|
+
"remaining_items": new_remaining,
|
|
325
|
+
"updated_at": utc_now(),
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
self._store.save_task(updated)
|
|
329
|
+
|
|
330
|
+
return TaskMutationResult(
|
|
331
|
+
task_id=updated.id,
|
|
332
|
+
updated_at=updated.updated_at.isoformat(),
|
|
333
|
+
completed=list(updated.completed),
|
|
334
|
+
remaining=list(updated.remaining),
|
|
335
|
+
current_work=updated.current_work,
|
|
336
|
+
known_issues=list(updated.known_issues),
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def record_issue(self, items: list[str] | str) -> TaskMutationResult:
|
|
340
|
+
"""Append deduplicated issues to Task.known_issues."""
|
|
341
|
+
self._ensure_can_mutate()
|
|
342
|
+
raw_items = [items] if isinstance(items, str) else items
|
|
343
|
+
if not raw_items:
|
|
344
|
+
raise ValueError("items cannot be empty.")
|
|
345
|
+
if len(raw_items) > MAX_ITEMS_PER_CALL:
|
|
346
|
+
raise ValueError(f"Too many items (maximum {MAX_ITEMS_PER_CALL}).")
|
|
347
|
+
|
|
348
|
+
cleaned: list[str] = []
|
|
349
|
+
for it in raw_items:
|
|
350
|
+
s = it.strip()
|
|
351
|
+
if not s:
|
|
352
|
+
continue
|
|
353
|
+
if len(s) > MAX_ITEM_CHARS:
|
|
354
|
+
raise ValueError(f"Issue exceeds maximum length of {MAX_ITEM_CHARS} characters.")
|
|
355
|
+
cleaned.append(s)
|
|
356
|
+
|
|
357
|
+
if not cleaned:
|
|
358
|
+
raise ValueError("No non-empty issues provided.")
|
|
359
|
+
|
|
360
|
+
task = self._get_bound_task()
|
|
361
|
+
|
|
362
|
+
new_issues = list(task.known_issues)
|
|
363
|
+
for it in cleaned:
|
|
364
|
+
if it not in new_issues:
|
|
365
|
+
new_issues.append(it)
|
|
366
|
+
|
|
367
|
+
updated = task.model_copy(
|
|
368
|
+
update={
|
|
369
|
+
"known_issues": new_issues,
|
|
370
|
+
"updated_at": utc_now(),
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
self._store.save_task(updated)
|
|
374
|
+
|
|
375
|
+
return TaskMutationResult(
|
|
376
|
+
task_id=updated.id,
|
|
377
|
+
updated_at=updated.updated_at.isoformat(),
|
|
378
|
+
completed=list(updated.completed),
|
|
379
|
+
remaining=list(updated.remaining),
|
|
380
|
+
current_work=updated.current_work,
|
|
381
|
+
known_issues=list(updated.known_issues),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def record_decision(self, decision: str) -> DecisionResult:
|
|
385
|
+
"""Record an architectural decision via a cooperative checkpoint.
|
|
386
|
+
|
|
387
|
+
Architectural decisions are persisted in CheckpointRecord.payload.decisions,
|
|
388
|
+
maintaining Task model purity. The checkpoint is immutable and is never rewritten
|
|
389
|
+
by later checkpoints; decisions survive provider handoffs because HandoffBuilder
|
|
390
|
+
aggregates them across the task's whole checkpoint history at handoff time.
|
|
391
|
+
|
|
392
|
+
The record carries a structured `trigger="decision"` marker in its existing
|
|
393
|
+
metadata mapping, per ADR-0009. That marker is provenance only: aggregation reads
|
|
394
|
+
every checkpoint's decisions regardless of trigger, and never parses operator_note.
|
|
395
|
+
"""
|
|
396
|
+
self._ensure_can_mutate()
|
|
397
|
+
cleaned = decision.strip()
|
|
398
|
+
if not cleaned:
|
|
399
|
+
raise ValueError("Decision cannot be empty or whitespace.")
|
|
400
|
+
if len(cleaned) > MAX_DECISION_CHARS:
|
|
401
|
+
raise ValueError(f"Decision exceeds maximum length of {MAX_DECISION_CHARS} characters.")
|
|
402
|
+
|
|
403
|
+
cp = self._create_checkpoint_internal(
|
|
404
|
+
decisions=[cleaned],
|
|
405
|
+
note="Recorded architectural decision via MCP",
|
|
406
|
+
metadata={CHECKPOINT_TRIGGER_KEY: CHECKPOINT_TRIGGER_DECISION},
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
return DecisionResult(
|
|
410
|
+
checkpoint_id=cp.id,
|
|
411
|
+
decision=cleaned,
|
|
412
|
+
created_at=cp.created_at.isoformat(),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def create_checkpoint(
|
|
416
|
+
self,
|
|
417
|
+
decisions: list[str] | None = None,
|
|
418
|
+
test_summary: str | None = None,
|
|
419
|
+
note: str | None = None,
|
|
420
|
+
) -> CreateCheckpointResult:
|
|
421
|
+
"""Capture a cooperative checkpoint with live repository snapshot and agent reports."""
|
|
422
|
+
self._ensure_can_mutate()
|
|
423
|
+
|
|
424
|
+
decisions_list: list[str] = []
|
|
425
|
+
if decisions:
|
|
426
|
+
if len(decisions) > MAX_ITEMS_PER_CALL:
|
|
427
|
+
raise ValueError(
|
|
428
|
+
f"Decisions list exceeds maximum capacity ({MAX_ITEMS_PER_CALL} items)."
|
|
429
|
+
)
|
|
430
|
+
for d in decisions:
|
|
431
|
+
cd = d.strip()
|
|
432
|
+
if not cd:
|
|
433
|
+
continue
|
|
434
|
+
if len(cd) > MAX_DECISION_CHARS:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
f"Decision exceeds maximum length of {MAX_DECISION_CHARS} characters."
|
|
437
|
+
)
|
|
438
|
+
decisions_list.append(cd)
|
|
439
|
+
|
|
440
|
+
if test_summary and len(test_summary) > MAX_TEST_SUMMARY_CHARS:
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"test_summary exceeds maximum length of {MAX_TEST_SUMMARY_CHARS} characters."
|
|
443
|
+
)
|
|
444
|
+
if note and len(note) > MAX_NOTE_CHARS:
|
|
445
|
+
raise ValueError(f"note exceeds maximum length of {MAX_NOTE_CHARS} characters.")
|
|
446
|
+
|
|
447
|
+
provenance = (
|
|
448
|
+
CheckpointTestProvenance.REPORTED if test_summary else CheckpointTestProvenance.UNKNOWN
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
cp = self._create_checkpoint_internal(
|
|
452
|
+
decisions=decisions_list,
|
|
453
|
+
test_summary=test_summary,
|
|
454
|
+
test_provenance=provenance,
|
|
455
|
+
note=note,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
test_stat = cp.payload.test_status
|
|
459
|
+
return CreateCheckpointResult(
|
|
460
|
+
checkpoint_id=cp.id,
|
|
461
|
+
task_id=cp.task_id,
|
|
462
|
+
kind=cp.kind.value,
|
|
463
|
+
created_at=cp.created_at.isoformat(),
|
|
464
|
+
decisions=list(cp.payload.decisions),
|
|
465
|
+
test_summary=test_stat.summary if test_stat.known else None,
|
|
466
|
+
test_provenance=test_stat.provenance.value if test_stat.known else None,
|
|
467
|
+
note=cp.payload.operator_note,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def _create_checkpoint_internal(
|
|
471
|
+
self,
|
|
472
|
+
decisions: list[str] | None = None,
|
|
473
|
+
test_summary: str | None = None,
|
|
474
|
+
test_provenance: CheckpointTestProvenance = CheckpointTestProvenance.UNKNOWN,
|
|
475
|
+
note: str | None = None,
|
|
476
|
+
metadata: dict[str, Any] | None = None,
|
|
477
|
+
) -> CheckpointRecord:
|
|
478
|
+
"""Internal helper to construct and persist a checkpoint record without locking."""
|
|
479
|
+
project = self._store.get_project(self._context.project_id)
|
|
480
|
+
if project is None:
|
|
481
|
+
raise NoActiveTaskError("Project record missing.")
|
|
482
|
+
|
|
483
|
+
task = self._get_bound_task()
|
|
484
|
+
session = (
|
|
485
|
+
self._store.get_session(self._context.session_id) if self._context.session_id else None
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# Live Git inspection
|
|
489
|
+
inspection = self._repo_service.inspect_repository(self._context.project_root)
|
|
490
|
+
snapshot_id: str | None = None
|
|
491
|
+
if (
|
|
492
|
+
inspection.status == RepositoryInspectionStatus.READY
|
|
493
|
+
and inspection.snapshot is not None
|
|
494
|
+
):
|
|
495
|
+
self._store.save_snapshot(inspection.snapshot)
|
|
496
|
+
snapshot_id = inspection.snapshot.id
|
|
497
|
+
|
|
498
|
+
record = CheckpointBuilder.build(
|
|
499
|
+
project=project,
|
|
500
|
+
task=task,
|
|
501
|
+
inspection=inspection,
|
|
502
|
+
kind=CheckpointKind.MANUAL,
|
|
503
|
+
snapshot_id=snapshot_id,
|
|
504
|
+
session=session,
|
|
505
|
+
decisions=decisions,
|
|
506
|
+
test_summary=test_summary,
|
|
507
|
+
test_provenance=test_provenance,
|
|
508
|
+
operator_note=note,
|
|
509
|
+
metadata=metadata,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
self._store.save_checkpoint(record)
|
|
513
|
+
return record
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Pydantic models and input limits for CortexShift MCP tools and resources."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from cortexshift.domain.task import (
|
|
8
|
+
MAX_CURRENT_WORK_CHARS,
|
|
9
|
+
MAX_ITEM_CHARS,
|
|
10
|
+
MAX_ITEMS_PER_CALL,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MAX_CHANGED_PATHS_BUDGET",
|
|
15
|
+
"MAX_CURRENT_WORK_CHARS",
|
|
16
|
+
"MAX_DECISION_CHARS",
|
|
17
|
+
"MAX_ITEMS_PER_CALL",
|
|
18
|
+
"MAX_ITEM_CHARS",
|
|
19
|
+
"MAX_NOTE_CHARS",
|
|
20
|
+
"MAX_TEST_SUMMARY_CHARS",
|
|
21
|
+
"CheckpointResult",
|
|
22
|
+
"CheckpointSummary",
|
|
23
|
+
"CreateCheckpointResult",
|
|
24
|
+
"DecisionResult",
|
|
25
|
+
"ProjectContextResult",
|
|
26
|
+
"ProjectSummary",
|
|
27
|
+
"RepositoryStatusSummary",
|
|
28
|
+
"SessionSummary",
|
|
29
|
+
"TaskMutationResult",
|
|
30
|
+
"TaskSummary",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Input safety bounds. Task text bounds are canonical domain limits; the remainder are
|
|
34
|
+
# MCP transport bounds.
|
|
35
|
+
MAX_DECISION_CHARS = 1000
|
|
36
|
+
MAX_TEST_SUMMARY_CHARS = 1000
|
|
37
|
+
MAX_NOTE_CHARS = 2000
|
|
38
|
+
MAX_CHANGED_PATHS_BUDGET = 50
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProjectSummary(BaseModel):
|
|
42
|
+
"""Concise summary of the bound project."""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(frozen=True)
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
name: str
|
|
48
|
+
root: str
|
|
49
|
+
active_task_id: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TaskSummary(BaseModel):
|
|
53
|
+
"""Concise summary of a task."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(frozen=True)
|
|
56
|
+
|
|
57
|
+
id: str
|
|
58
|
+
title: str
|
|
59
|
+
status: str
|
|
60
|
+
objective: str
|
|
61
|
+
current_work: str | None = None
|
|
62
|
+
completed_count: int
|
|
63
|
+
remaining_count: int
|
|
64
|
+
known_issues_count: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SessionSummary(BaseModel):
|
|
68
|
+
"""Summary of the bound CortexShift session."""
|
|
69
|
+
|
|
70
|
+
model_config = ConfigDict(frozen=True)
|
|
71
|
+
|
|
72
|
+
id: str
|
|
73
|
+
provider_id: str
|
|
74
|
+
status: str
|
|
75
|
+
started_at: str
|
|
76
|
+
native_session_id: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CheckpointSummary(BaseModel):
|
|
80
|
+
"""Summary of a checkpoint record."""
|
|
81
|
+
|
|
82
|
+
model_config = ConfigDict(frozen=True)
|
|
83
|
+
|
|
84
|
+
id: str
|
|
85
|
+
kind: str
|
|
86
|
+
created_at: str
|
|
87
|
+
decisions: list[str] = Field(default_factory=list)
|
|
88
|
+
test_summary: str | None = None
|
|
89
|
+
test_provenance: str | None = None
|
|
90
|
+
note: str | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RepositoryStatusSummary(BaseModel):
|
|
94
|
+
"""Bounded summary of live repository status."""
|
|
95
|
+
|
|
96
|
+
model_config = ConfigDict(frozen=True)
|
|
97
|
+
|
|
98
|
+
branch: str | None = None
|
|
99
|
+
head_commit: str | None = None
|
|
100
|
+
dirty: bool = False
|
|
101
|
+
staged_count: int = 0
|
|
102
|
+
modified_count: int = 0
|
|
103
|
+
untracked_count: int = 0
|
|
104
|
+
conflicted_count: int = 0
|
|
105
|
+
diff_shortstat: str | None = None
|
|
106
|
+
changed_paths: list[str] = Field(default_factory=list)
|
|
107
|
+
omitted_paths_count: int = 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
DEFAULT_TRUTH_HIERARCHY = [
|
|
111
|
+
"1. Actual repository files (the filesystem is primary reality)",
|
|
112
|
+
"2. Git state (branch, commits, status, diffs)",
|
|
113
|
+
"3. Verified command/test results (executed by you in this session)",
|
|
114
|
+
"4. CortexShift canonical task state (persisted task records)",
|
|
115
|
+
"5. Previous agent summaries/handoffs (advisory only)",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ProjectContextResult(BaseModel):
|
|
120
|
+
"""Structured response for get_project_context."""
|
|
121
|
+
|
|
122
|
+
model_config = ConfigDict(frozen=True)
|
|
123
|
+
|
|
124
|
+
project: ProjectSummary
|
|
125
|
+
task: TaskSummary | None = None
|
|
126
|
+
session: SessionSummary | None = None
|
|
127
|
+
latest_checkpoint: CheckpointSummary | None = None
|
|
128
|
+
repository: RepositoryStatusSummary | None = None
|
|
129
|
+
truth_hierarchy: list[str] = Field(default_factory=lambda: list(DEFAULT_TRUTH_HIERARCHY))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TaskMutationResult(BaseModel):
|
|
133
|
+
"""Structured response returned by task write tools."""
|
|
134
|
+
|
|
135
|
+
model_config = ConfigDict(frozen=True)
|
|
136
|
+
|
|
137
|
+
success: bool = True
|
|
138
|
+
task_id: str
|
|
139
|
+
updated_at: str
|
|
140
|
+
completed: list[str]
|
|
141
|
+
remaining: list[str]
|
|
142
|
+
current_work: str | None = None
|
|
143
|
+
known_issues: list[str] = Field(default_factory=list)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class DecisionResult(BaseModel):
|
|
147
|
+
"""Structured response for record_decision."""
|
|
148
|
+
|
|
149
|
+
model_config = ConfigDict(frozen=True)
|
|
150
|
+
|
|
151
|
+
success: bool = True
|
|
152
|
+
checkpoint_id: str
|
|
153
|
+
decision: str
|
|
154
|
+
created_at: str
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CreateCheckpointResult(BaseModel):
|
|
158
|
+
"""Structured response for create_checkpoint."""
|
|
159
|
+
|
|
160
|
+
model_config = ConfigDict(frozen=True)
|
|
161
|
+
|
|
162
|
+
success: bool = True
|
|
163
|
+
checkpoint_id: str
|
|
164
|
+
task_id: str
|
|
165
|
+
kind: str
|
|
166
|
+
created_at: str
|
|
167
|
+
decisions: list[str] = Field(default_factory=list)
|
|
168
|
+
test_summary: str | None = None
|
|
169
|
+
test_provenance: str | None = None
|
|
170
|
+
note: str | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class CheckpointResult(BaseModel):
|
|
174
|
+
"""Structured response for get_latest_checkpoint."""
|
|
175
|
+
|
|
176
|
+
model_config = ConfigDict(frozen=True)
|
|
177
|
+
|
|
178
|
+
checkpoint: dict[str, Any] | None = None
|