draft-board 0.1.0-beta.0

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 (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. package/package.json +37 -0
@@ -0,0 +1,536 @@
1
+ """Cursor agent JSON log normalizer.
2
+
3
+ Parses cursor-agent's stream-json output and converts it to normalized
4
+ log entries for display in the UI. Modeled after vibe-kanban's Rust implementation.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from dataclasses import dataclass, field
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class NormalizedEntryType(StrEnum):
17
+ """Types of normalized log entries."""
18
+
19
+ SYSTEM_MESSAGE = "system_message"
20
+ ASSISTANT_MESSAGE = "assistant_message"
21
+ THINKING = "thinking"
22
+ TOOL_USE = "tool_use"
23
+ ERROR_MESSAGE = "error_message"
24
+
25
+
26
+ class ToolStatus(StrEnum):
27
+ """Status of a tool call."""
28
+
29
+ CREATED = "created"
30
+ RUNNING = "running"
31
+ COMPLETED = "completed"
32
+ FAILED = "failed"
33
+
34
+
35
+ class ToolActionType(StrEnum):
36
+ """Type of action a tool performs."""
37
+
38
+ READ_FILE = "read_file"
39
+ WRITE_FILE = "write_file"
40
+ EDIT_FILE = "edit_file"
41
+ LIST_DIR = "list_dir"
42
+ SEARCH = "search"
43
+ SHELL = "shell"
44
+ UNKNOWN = "unknown"
45
+
46
+
47
+ @dataclass
48
+ class NormalizedEntry:
49
+ """A normalized log entry for display."""
50
+
51
+ entry_type: NormalizedEntryType
52
+ content: str
53
+ sequence: int = 0
54
+ tool_name: str | None = None
55
+ action_type: ToolActionType | None = None
56
+ tool_status: ToolStatus | None = None
57
+ metadata: dict[str, Any] = field(default_factory=dict)
58
+
59
+
60
+ class CursorLogNormalizer:
61
+ """Normalizes cursor-agent JSON streaming output.
62
+
63
+ Handles message coalescing (combining streaming deltas into complete messages)
64
+ and converts various JSON event types into normalized entries.
65
+ """
66
+
67
+ def __init__(self, worktree_path: str = ""):
68
+ self.worktree_path = worktree_path
69
+ self.sequence = 0
70
+
71
+ # Coalescing state
72
+ self.current_thinking_buffer = ""
73
+ self.current_thinking_sequence: int | None = None
74
+ self.current_assistant_buffer = ""
75
+ self.current_assistant_sequence: int | None = None
76
+
77
+ # Track tool calls by call_id
78
+ self.tool_call_sequences: dict[str, int] = {}
79
+
80
+ # Model info
81
+ self.model_reported = False
82
+ self.session_id_reported = False
83
+ self.session_id: str | None = None
84
+
85
+ def _next_sequence(self) -> int:
86
+ """Get next sequence number."""
87
+ seq = self.sequence
88
+ self.sequence += 1
89
+ return seq
90
+
91
+ def _strip_worktree_prefix(self, path: str) -> str:
92
+ """Strip worktree path prefix from file paths for cleaner display."""
93
+ if self.worktree_path and path.startswith(self.worktree_path):
94
+ return path[len(self.worktree_path) :].lstrip("/")
95
+ return path
96
+
97
+ def process_line(self, line: str) -> list[NormalizedEntry]:
98
+ """Process a single JSON line and return normalized entries.
99
+
100
+ Returns a list because some lines may produce multiple entries
101
+ (e.g., flushing buffers when switching message types).
102
+
103
+ Supports both Cursor Agent stream-json format AND Claude CLI
104
+ stream-json format (with --output-format stream-json --verbose
105
+ --include-partial-messages).
106
+ """
107
+ if not line.strip():
108
+ return []
109
+
110
+ try:
111
+ data = json.loads(line)
112
+ except json.JSONDecodeError:
113
+ data = None
114
+
115
+ if not isinstance(data, dict):
116
+ # Non-JSON line or JSON non-object (string, array, etc.) - treat as system message
117
+ stripped = line.strip()
118
+ if stripped:
119
+ # Filter out [DEBUG] lines — they are internal diagnostics
120
+ if stripped.startswith("[DEBUG]"):
121
+ return []
122
+ return [
123
+ NormalizedEntry(
124
+ entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
125
+ content=stripped,
126
+ sequence=self._next_sequence(),
127
+ )
128
+ ]
129
+ return []
130
+
131
+ entries: list[NormalizedEntry] = []
132
+ msg_type = data.get("type", "")
133
+
134
+ # Extract session_id if present
135
+ if not self.session_id_reported:
136
+ session_id = data.get("session_id")
137
+ if session_id:
138
+ self.session_id = session_id
139
+ self.session_id_reported = True
140
+
141
+ # Check if we need to flush buffers (switching message types)
142
+ # stream_event with text deltas counts as "assistant-like"
143
+ is_thinking = msg_type == "thinking"
144
+ is_assistant = msg_type == "assistant"
145
+ is_stream_delta = msg_type == "stream_event"
146
+
147
+ if (
148
+ not is_thinking
149
+ and not is_stream_delta
150
+ and self.current_thinking_sequence is not None
151
+ ):
152
+ # Flush thinking buffer
153
+ self.current_thinking_sequence = None
154
+ self.current_thinking_buffer = ""
155
+
156
+ if (
157
+ not is_assistant
158
+ and not is_stream_delta
159
+ and self.current_assistant_sequence is not None
160
+ ):
161
+ # Flush assistant buffer
162
+ self.current_assistant_sequence = None
163
+ self.current_assistant_buffer = ""
164
+
165
+ # Process by message type
166
+ if msg_type == "system":
167
+ entries.extend(self._process_system(data))
168
+ elif msg_type == "user":
169
+ # Skip user messages (just the prompt echo)
170
+ pass
171
+ elif msg_type == "assistant":
172
+ entries.extend(self._process_assistant(data))
173
+ elif msg_type == "thinking":
174
+ entries.extend(self._process_thinking(data))
175
+ elif msg_type == "tool_call":
176
+ entries.extend(self._process_tool_call(data))
177
+ elif msg_type == "result":
178
+ entries.extend(self._process_result(data))
179
+ elif msg_type == "stream_event":
180
+ # Claude CLI streaming events (--include-partial-messages)
181
+ entries.extend(self._process_stream_event(data))
182
+ elif msg_type == "rate_limit_event":
183
+ # Skip rate limit events — not useful for display
184
+ pass
185
+ else:
186
+ # Unknown type - log as system message
187
+ entries.append(
188
+ NormalizedEntry(
189
+ entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
190
+ content=f"[{msg_type}] {json.dumps(data)}",
191
+ sequence=self._next_sequence(),
192
+ )
193
+ )
194
+
195
+ return entries
196
+
197
+ def _process_system(self, data: dict) -> list[NormalizedEntry]:
198
+ """Process system initialization message."""
199
+ entries = []
200
+
201
+ if not self.model_reported:
202
+ model = data.get("model")
203
+ if model:
204
+ entries.append(
205
+ NormalizedEntry(
206
+ entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
207
+ content=f"🤖 Model: {model}",
208
+ sequence=self._next_sequence(),
209
+ metadata={"model": model},
210
+ )
211
+ )
212
+ self.model_reported = True
213
+
214
+ return entries
215
+
216
+ def _process_assistant(self, data: dict) -> list[NormalizedEntry]:
217
+ """Process assistant message (may be streaming chunks)."""
218
+ message = data.get("message", {})
219
+ content_parts = message.get("content", [])
220
+
221
+ # Extract text from content parts
222
+ text = ""
223
+ for part in content_parts:
224
+ if isinstance(part, dict) and part.get("type") == "text":
225
+ text += part.get("text", "")
226
+ elif isinstance(part, str):
227
+ text += part
228
+
229
+ if not text:
230
+ return []
231
+
232
+ # Coalesce streaming messages
233
+ self.current_assistant_buffer += text
234
+
235
+ if self.current_assistant_sequence is None:
236
+ self.current_assistant_sequence = self._next_sequence()
237
+
238
+ return [
239
+ NormalizedEntry(
240
+ entry_type=NormalizedEntryType.ASSISTANT_MESSAGE,
241
+ content=self.current_assistant_buffer,
242
+ sequence=self.current_assistant_sequence,
243
+ )
244
+ ]
245
+
246
+ def _process_thinking(self, data: dict) -> list[NormalizedEntry]:
247
+ """Process thinking message (streaming deltas)."""
248
+ subtype = data.get("subtype", "")
249
+
250
+ if subtype == "delta":
251
+ text = data.get("text", "")
252
+ if text:
253
+ self.current_thinking_buffer += text
254
+
255
+ if self.current_thinking_sequence is None:
256
+ self.current_thinking_sequence = self._next_sequence()
257
+
258
+ return [
259
+ NormalizedEntry(
260
+ entry_type=NormalizedEntryType.THINKING,
261
+ content=self.current_thinking_buffer,
262
+ sequence=self.current_thinking_sequence,
263
+ metadata={"collapsed": True},
264
+ )
265
+ ]
266
+ elif subtype == "completed":
267
+ # Thinking completed - keep current buffer
268
+ pass
269
+
270
+ return []
271
+
272
+ def _process_tool_call(self, data: dict) -> list[NormalizedEntry]:
273
+ """Process tool call start/complete."""
274
+ subtype = data.get("subtype", "")
275
+ call_id = data.get("call_id", "")
276
+ tool_call = data.get("tool_call", {})
277
+
278
+ # Determine tool name and action type
279
+ tool_name, action_type, content = self._parse_tool_call(tool_call)
280
+
281
+ if subtype == "started":
282
+ seq = self._next_sequence()
283
+ if call_id:
284
+ self.tool_call_sequences[call_id] = seq
285
+
286
+ return [
287
+ NormalizedEntry(
288
+ entry_type=NormalizedEntryType.TOOL_USE,
289
+ content=content,
290
+ sequence=seq,
291
+ tool_name=tool_name,
292
+ action_type=action_type,
293
+ tool_status=ToolStatus.CREATED,
294
+ )
295
+ ]
296
+ elif subtype == "completed":
297
+ # Update existing entry with result
298
+ seq = self.tool_call_sequences.get(call_id, self._next_sequence())
299
+
300
+ # Extract result info
301
+ result_content = self._extract_tool_result(tool_call)
302
+ if result_content:
303
+ content = f"{content}\n→ {result_content}"
304
+
305
+ return [
306
+ NormalizedEntry(
307
+ entry_type=NormalizedEntryType.TOOL_USE,
308
+ content=content,
309
+ sequence=seq,
310
+ tool_name=tool_name,
311
+ action_type=action_type,
312
+ tool_status=ToolStatus.COMPLETED,
313
+ )
314
+ ]
315
+
316
+ return []
317
+
318
+ def _parse_tool_call(self, tool_call: dict) -> tuple[str, ToolActionType, str]:
319
+ """Parse tool call data to extract name, action type, and display content."""
320
+ # Check various tool call formats
321
+ if "readToolCall" in tool_call:
322
+ args = tool_call["readToolCall"].get("args", {})
323
+ path = self._strip_worktree_prefix(args.get("path", "unknown"))
324
+ return "read_file", ToolActionType.READ_FILE, f"📖 Read: {path}"
325
+
326
+ if "editToolCall" in tool_call:
327
+ args = tool_call["editToolCall"].get("args", {})
328
+ path = self._strip_worktree_prefix(args.get("path", "unknown"))
329
+ return "edit_file", ToolActionType.EDIT_FILE, f"✏️ Edit: {path}"
330
+
331
+ if "lsToolCall" in tool_call:
332
+ args = tool_call["lsToolCall"].get("args", {})
333
+ path = self._strip_worktree_prefix(args.get("path", "."))
334
+ return "list_dir", ToolActionType.LIST_DIR, f"📁 List: {path}"
335
+
336
+ if "globToolCall" in tool_call:
337
+ args = tool_call["globToolCall"].get("args", {})
338
+ pattern = args.get("globPattern", "*")
339
+ return "glob", ToolActionType.SEARCH, f"🔍 Glob: {pattern}"
340
+
341
+ if "grepToolCall" in tool_call:
342
+ args = tool_call["grepToolCall"].get("args", {})
343
+ pattern = args.get("pattern", "")
344
+ return "grep", ToolActionType.SEARCH, f"🔍 Grep: {pattern}"
345
+
346
+ if "shellToolCall" in tool_call:
347
+ args = tool_call["shellToolCall"].get("args", {})
348
+ command = args.get("command", "")
349
+ return "shell", ToolActionType.SHELL, f"💻 Shell: {command}"
350
+
351
+ # Generic/unknown tool
352
+ return (
353
+ "unknown",
354
+ ToolActionType.UNKNOWN,
355
+ f"🔧 Tool call: {json.dumps(tool_call)[:100]}",
356
+ )
357
+
358
+ def _extract_tool_result(self, tool_call: dict) -> str:
359
+ """Extract a summary of tool result for display."""
360
+ for key in [
361
+ "readToolCall",
362
+ "editToolCall",
363
+ "lsToolCall",
364
+ "globToolCall",
365
+ "grepToolCall",
366
+ "shellToolCall",
367
+ ]:
368
+ if key in tool_call:
369
+ result = tool_call[key].get("result", {})
370
+ if "success" in result:
371
+ success = result["success"]
372
+ if key == "editToolCall":
373
+ lines_added = success.get("linesAdded", 0)
374
+ lines_removed = success.get("linesRemoved", 0)
375
+ return f"+{lines_added} -{lines_removed} lines"
376
+ elif key == "shellToolCall":
377
+ exit_code = success.get("exitCode", 0)
378
+ return f"exit code: {exit_code}"
379
+ elif key == "globToolCall":
380
+ total = success.get("totalFiles", 0)
381
+ return f"{total} files"
382
+ elif "error" in result:
383
+ return f"❌ {result['error'][:50]}"
384
+ return ""
385
+
386
+ def _process_stream_event(self, data: dict) -> list[NormalizedEntry]:
387
+ """Process Claude CLI stream events (from --include-partial-messages).
388
+
389
+ These are Anthropic API streaming events wrapped in Claude CLI's format:
390
+ - content_block_delta with text_delta → streaming assistant text
391
+ - content_block_delta with thinking_delta → streaming thinking text
392
+ - message_start, content_block_start/stop, message_delta/stop → lifecycle events (skip)
393
+ """
394
+ event = data.get("event", {})
395
+ event_type = event.get("type", "")
396
+
397
+ if event_type == "content_block_delta":
398
+ delta = event.get("delta", {})
399
+ delta_type = delta.get("type", "")
400
+
401
+ if delta_type == "text_delta":
402
+ text = delta.get("text", "")
403
+ if text:
404
+ self.current_assistant_buffer += text
405
+ if self.current_assistant_sequence is None:
406
+ self.current_assistant_sequence = self._next_sequence()
407
+ return [
408
+ NormalizedEntry(
409
+ entry_type=NormalizedEntryType.ASSISTANT_MESSAGE,
410
+ content=self.current_assistant_buffer,
411
+ sequence=self.current_assistant_sequence,
412
+ )
413
+ ]
414
+
415
+ elif delta_type == "thinking_delta":
416
+ text = delta.get("thinking", "")
417
+ if text:
418
+ self.current_thinking_buffer += text
419
+ if self.current_thinking_sequence is None:
420
+ self.current_thinking_sequence = self._next_sequence()
421
+ return [
422
+ NormalizedEntry(
423
+ entry_type=NormalizedEntryType.THINKING,
424
+ content=self.current_thinking_buffer,
425
+ sequence=self.current_thinking_sequence,
426
+ metadata={"collapsed": True},
427
+ )
428
+ ]
429
+
430
+ elif event_type == "content_block_start":
431
+ # Check if this is a thinking block start
432
+ block = event.get("content_block", {})
433
+ if block.get("type") == "thinking":
434
+ # Reset thinking buffer for a new thinking block
435
+ self.current_thinking_buffer = ""
436
+ self.current_thinking_sequence = None
437
+
438
+ # Skip other lifecycle events (message_start, content_block_stop, etc.)
439
+ return []
440
+
441
+ def _process_result(self, data: dict) -> list[NormalizedEntry]:
442
+ """Process final result message.
443
+
444
+ Handles both Cursor Agent format (result is dict with 'outcome')
445
+ and Claude CLI format (result is the response text string).
446
+ """
447
+ result = data.get("result", {})
448
+ subtype = data.get("subtype", "")
449
+ is_error = data.get("is_error", False)
450
+
451
+ if isinstance(result, str):
452
+ # Claude CLI format: result is the text response
453
+ if is_error:
454
+ return [
455
+ NormalizedEntry(
456
+ entry_type=NormalizedEntryType.ERROR_MESSAGE,
457
+ content=f"Agent error: {result[:200]}",
458
+ sequence=self._next_sequence(),
459
+ metadata={"outcome": subtype or "error"},
460
+ )
461
+ ]
462
+ else:
463
+ return [
464
+ NormalizedEntry(
465
+ entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
466
+ content=f"✅ Agent finished ({subtype or 'success'})",
467
+ sequence=self._next_sequence(),
468
+ metadata={"outcome": subtype or "success"},
469
+ )
470
+ ]
471
+ elif isinstance(result, dict):
472
+ outcome = result.get("outcome", "unknown")
473
+ return [
474
+ NormalizedEntry(
475
+ entry_type=NormalizedEntryType.SYSTEM_MESSAGE,
476
+ content=f"✅ Completed: {outcome}",
477
+ sequence=self._next_sequence(),
478
+ metadata={"outcome": outcome},
479
+ )
480
+ ]
481
+ return []
482
+
483
+ def finalize(self) -> list[NormalizedEntry]:
484
+ """Finalize and return any remaining buffered entries."""
485
+ entries = []
486
+
487
+ if self.current_thinking_buffer and self.current_thinking_sequence is not None:
488
+ entries.append(
489
+ NormalizedEntry(
490
+ entry_type=NormalizedEntryType.THINKING,
491
+ content=self.current_thinking_buffer,
492
+ sequence=self.current_thinking_sequence,
493
+ )
494
+ )
495
+
496
+ if (
497
+ self.current_assistant_buffer
498
+ and self.current_assistant_sequence is not None
499
+ ):
500
+ entries.append(
501
+ NormalizedEntry(
502
+ entry_type=NormalizedEntryType.ASSISTANT_MESSAGE,
503
+ content=self.current_assistant_buffer,
504
+ sequence=self.current_assistant_sequence,
505
+ )
506
+ )
507
+
508
+ return entries
509
+
510
+
511
+ def normalize_cursor_output(
512
+ raw_output: str, worktree_path: str = ""
513
+ ) -> list[NormalizedEntry]:
514
+ """Convenience function to normalize complete cursor output.
515
+
516
+ Args:
517
+ raw_output: Raw stdout from cursor-agent with JSON lines.
518
+ worktree_path: Optional worktree path to strip from file paths.
519
+
520
+ Returns:
521
+ List of normalized entries.
522
+ """
523
+ normalizer = CursorLogNormalizer(worktree_path)
524
+ entries = []
525
+
526
+ for line in raw_output.splitlines():
527
+ entries.extend(normalizer.process_line(line))
528
+
529
+ entries.extend(normalizer.finalize())
530
+
531
+ # Deduplicate by sequence (keep latest version)
532
+ seen_sequences: dict[int, NormalizedEntry] = {}
533
+ for entry in entries:
534
+ seen_sequences[entry.sequence] = entry
535
+
536
+ return sorted(seen_sequences.values(), key=lambda e: e.sequence)