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