anvil-dev-framework 0.1.6

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 (190) hide show
  1. package/README.md +719 -0
  2. package/VERSION +1 -0
  3. package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
  4. package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
  5. package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
  6. package/docs/INSTALLATION.md +984 -0
  7. package/docs/anvil-hud.md +469 -0
  8. package/docs/anvil-init.md +255 -0
  9. package/docs/anvil-state.md +210 -0
  10. package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
  11. package/docs/command-reference.md +2022 -0
  12. package/docs/hooks-tts.md +368 -0
  13. package/docs/implementation-guide.md +810 -0
  14. package/docs/linear-github-integration.md +247 -0
  15. package/docs/local-issues.md +677 -0
  16. package/docs/patterns/README.md +419 -0
  17. package/docs/planning-responsibilities.md +139 -0
  18. package/docs/session-workflow.md +573 -0
  19. package/docs/simplification-plan-template.md +297 -0
  20. package/docs/simplification-principles.md +129 -0
  21. package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
  22. package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
  23. package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
  24. package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
  25. package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
  26. package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
  27. package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
  28. package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
  29. package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
  30. package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
  31. package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
  32. package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
  33. package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
  34. package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
  35. package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
  36. package/docs/sync.md +122 -0
  37. package/global/CLAUDE.md +140 -0
  38. package/global/agents/verify-app.md +164 -0
  39. package/global/commands/anvil-settings.md +527 -0
  40. package/global/commands/anvil-sync.md +121 -0
  41. package/global/commands/change.md +197 -0
  42. package/global/commands/clarify.md +252 -0
  43. package/global/commands/cleanup.md +292 -0
  44. package/global/commands/commit-push-pr.md +207 -0
  45. package/global/commands/decay-review.md +127 -0
  46. package/global/commands/discover.md +158 -0
  47. package/global/commands/doc-coverage.md +122 -0
  48. package/global/commands/evidence.md +307 -0
  49. package/global/commands/explore.md +121 -0
  50. package/global/commands/force-exit.md +135 -0
  51. package/global/commands/handoff.md +191 -0
  52. package/global/commands/healthcheck.md +302 -0
  53. package/global/commands/hud.md +84 -0
  54. package/global/commands/insights.md +319 -0
  55. package/global/commands/linear-setup.md +184 -0
  56. package/global/commands/lint-fix.md +198 -0
  57. package/global/commands/orient.md +510 -0
  58. package/global/commands/plan.md +228 -0
  59. package/global/commands/ralph.md +346 -0
  60. package/global/commands/ready.md +182 -0
  61. package/global/commands/release.md +305 -0
  62. package/global/commands/retro.md +96 -0
  63. package/global/commands/shard.md +166 -0
  64. package/global/commands/spec.md +227 -0
  65. package/global/commands/sprint.md +184 -0
  66. package/global/commands/tasks.md +228 -0
  67. package/global/commands/test-and-commit.md +151 -0
  68. package/global/commands/validate.md +132 -0
  69. package/global/commands/verify.md +251 -0
  70. package/global/commands/weekly-review.md +156 -0
  71. package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
  72. package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
  73. package/global/hooks/anvil_memory_observe.ts +322 -0
  74. package/global/hooks/anvil_memory_session.ts +166 -0
  75. package/global/hooks/anvil_memory_stop.ts +187 -0
  76. package/global/hooks/parse_transcript.py +116 -0
  77. package/global/hooks/post_merge_cleanup.sh +132 -0
  78. package/global/hooks/post_tool_format.sh +215 -0
  79. package/global/hooks/ralph_context_monitor.py +240 -0
  80. package/global/hooks/ralph_stop.sh +502 -0
  81. package/global/hooks/statusline.sh +1110 -0
  82. package/global/hooks/statusline_agent_sync.py +224 -0
  83. package/global/hooks/stop_gate.sh +250 -0
  84. package/global/lib/.claude/anvil-state.json +21 -0
  85. package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
  86. package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
  87. package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
  88. package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
  89. package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
  90. package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
  91. package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
  92. package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
  93. package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
  94. package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
  95. package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
  96. package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
  97. package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
  98. package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
  99. package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
  100. package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
  101. package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
  102. package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
  103. package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
  104. package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
  105. package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
  106. package/global/lib/agent_registry.py +995 -0
  107. package/global/lib/anvil-state.sh +435 -0
  108. package/global/lib/claim_service.py +515 -0
  109. package/global/lib/coderabbit_service.py +314 -0
  110. package/global/lib/config_service.py +423 -0
  111. package/global/lib/coordination_service.py +331 -0
  112. package/global/lib/doc_coverage_service.py +1305 -0
  113. package/global/lib/gate_logger.py +316 -0
  114. package/global/lib/github_service.py +310 -0
  115. package/global/lib/handoff_generator.py +775 -0
  116. package/global/lib/hygiene_service.py +712 -0
  117. package/global/lib/issue_models.py +257 -0
  118. package/global/lib/issue_provider.py +339 -0
  119. package/global/lib/linear_data_service.py +210 -0
  120. package/global/lib/linear_provider.py +987 -0
  121. package/global/lib/linear_provider.py.backup +671 -0
  122. package/global/lib/local_provider.py +486 -0
  123. package/global/lib/orient_fast.py +457 -0
  124. package/global/lib/quality_service.py +470 -0
  125. package/global/lib/ralph_prompt_generator.py +563 -0
  126. package/global/lib/ralph_state.py +1202 -0
  127. package/global/lib/state_manager.py +417 -0
  128. package/global/lib/transcript_parser.py +597 -0
  129. package/global/lib/verification_runner.py +557 -0
  130. package/global/lib/verify_iteration.py +490 -0
  131. package/global/lib/verify_subagent.py +250 -0
  132. package/global/skills/README.md +155 -0
  133. package/global/skills/quality-gates/SKILL.md +252 -0
  134. package/global/skills/skill-template/SKILL.md +109 -0
  135. package/global/skills/testing-strategies/SKILL.md +337 -0
  136. package/global/templates/CHANGE-template.md +105 -0
  137. package/global/templates/HANDOFF-template.md +63 -0
  138. package/global/templates/PLAN-template.md +111 -0
  139. package/global/templates/SPEC-template.md +93 -0
  140. package/global/templates/ralph/PROMPT.md.template +89 -0
  141. package/global/templates/ralph/fix_plan.md.template +31 -0
  142. package/global/templates/ralph/progress.txt.template +23 -0
  143. package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
  144. package/global/tests/test_doc_coverage.py +520 -0
  145. package/global/tests/test_issue_models.py +299 -0
  146. package/global/tests/test_local_provider.py +323 -0
  147. package/global/tools/README.md +178 -0
  148. package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
  149. package/global/tools/anvil-hud.py +3622 -0
  150. package/global/tools/anvil-hud.py.bak +3318 -0
  151. package/global/tools/anvil-issue.py +432 -0
  152. package/global/tools/anvil-memory/CLAUDE.md +49 -0
  153. package/global/tools/anvil-memory/README.md +42 -0
  154. package/global/tools/anvil-memory/bun.lock +25 -0
  155. package/global/tools/anvil-memory/bunfig.toml +9 -0
  156. package/global/tools/anvil-memory/package.json +23 -0
  157. package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
  158. package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
  159. package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
  160. package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
  161. package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
  162. package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
  163. package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
  164. package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
  165. package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
  166. package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
  167. package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
  168. package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
  169. package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
  170. package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
  171. package/global/tools/anvil-memory/src/commands/get.ts +115 -0
  172. package/global/tools/anvil-memory/src/commands/init.ts +94 -0
  173. package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
  174. package/global/tools/anvil-memory/src/commands/search.ts +112 -0
  175. package/global/tools/anvil-memory/src/db.ts +638 -0
  176. package/global/tools/anvil-memory/src/index.ts +205 -0
  177. package/global/tools/anvil-memory/src/types.ts +122 -0
  178. package/global/tools/anvil-memory/tsconfig.json +29 -0
  179. package/global/tools/ralph-loop.sh +359 -0
  180. package/package.json +45 -0
  181. package/scripts/anvil +822 -0
  182. package/scripts/extract_patterns.py +222 -0
  183. package/scripts/init-project.sh +541 -0
  184. package/scripts/install.sh +229 -0
  185. package/scripts/postinstall.js +41 -0
  186. package/scripts/rollback.sh +188 -0
  187. package/scripts/sync.sh +623 -0
  188. package/scripts/test-statusline.sh +248 -0
  189. package/scripts/update_claude_md.py +224 -0
  190. package/scripts/verify.sh +255 -0
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ transcript_parser.py - JSONL transcript parsing for Anvil statusline and HUD
4
+
5
+ Parses Claude Code conversation transcripts to extract:
6
+ - Tool usage (running/completed tools)
7
+ - Todo state from TodoWrite calls
8
+ - Error counts and patterns
9
+
10
+ Usage:
11
+ from transcript_parser import TranscriptParser
12
+
13
+ parser = TranscriptParser()
14
+ activity = parser.parse("/path/to/transcript.jsonl")
15
+ todos = parser.get_todos("/path/to/transcript.jsonl")
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime, timezone
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+
26
+ # =============================================================================
27
+ # Data Structures
28
+ # =============================================================================
29
+
30
+
31
+ @dataclass
32
+ class ToolEntry:
33
+ """Represents a single tool invocation."""
34
+ name: str
35
+ tool_use_id: str
36
+ status: str # "running", "completed", "error"
37
+ target: Optional[str] = None # File path or pattern if applicable
38
+ started_at: Optional[str] = None
39
+ completed_at: Optional[str] = None
40
+ duration_ms: Optional[int] = None
41
+ error: Optional[str] = None
42
+ input_preview: Optional[str] = None # Truncated input for display
43
+
44
+
45
+ @dataclass
46
+ class TodoItem:
47
+ """Represents a single todo item."""
48
+ content: str
49
+ status: str # "pending", "in_progress", "completed"
50
+ active_form: Optional[str] = None
51
+
52
+
53
+ @dataclass
54
+ class TodoState:
55
+ """Current state of todos from most recent TodoWrite."""
56
+ items: List[TodoItem] = field(default_factory=list)
57
+ total: int = 0
58
+ completed: int = 0
59
+ in_progress: Optional[TodoItem] = None
60
+ timestamp: Optional[str] = None
61
+
62
+ @property
63
+ def pending(self) -> int:
64
+ """Count of pending items."""
65
+ return self.total - self.completed - (1 if self.in_progress else 0)
66
+
67
+
68
+ @dataclass
69
+ class ToolActivityState:
70
+ """Aggregate tool activity state."""
71
+ running: List[ToolEntry] = field(default_factory=list)
72
+ completed: List[ToolEntry] = field(default_factory=list)
73
+ error_count: int = 0
74
+ tool_counts: Dict[str, int] = field(default_factory=dict)
75
+ last_updated: Optional[str] = None
76
+
77
+ def get_top_completed(self, limit: int = 4) -> List[Tuple[str, int]]:
78
+ """Get top N completed tools by frequency."""
79
+ sorted_tools = sorted(
80
+ self.tool_counts.items(),
81
+ key=lambda x: x[1],
82
+ reverse=True
83
+ )
84
+ return sorted_tools[:limit]
85
+
86
+
87
+ # =============================================================================
88
+ # Cache Implementation
89
+ # =============================================================================
90
+
91
+
92
+ class ParseCache:
93
+ """Simple TTL cache for parsed transcript data."""
94
+
95
+ def __init__(self, ttl_seconds: float = 1.0):
96
+ self.ttl = ttl_seconds
97
+ self._cache: Dict[str, Tuple[float, Any]] = {}
98
+
99
+ def get(self, key: str) -> Optional[Any]:
100
+ """Get cached value if not expired."""
101
+ if key not in self._cache:
102
+ return None
103
+
104
+ timestamp, value = self._cache[key]
105
+ if time.time() - timestamp > self.ttl:
106
+ del self._cache[key]
107
+ return None
108
+
109
+ return value
110
+
111
+ def set(self, key: str, value: Any) -> None:
112
+ """Cache a value with current timestamp."""
113
+ self._cache[key] = (time.time(), value)
114
+
115
+ def invalidate(self, key: str) -> None:
116
+ """Remove a key from cache."""
117
+ self._cache.pop(key, None)
118
+
119
+ def clear(self) -> None:
120
+ """Clear all cached values."""
121
+ self._cache.clear()
122
+
123
+
124
+ # =============================================================================
125
+ # Transcript Parser
126
+ # =============================================================================
127
+
128
+
129
+ class TranscriptParser:
130
+ """Parses Claude Code JSONL transcripts for tool and todo information."""
131
+
132
+ # Tools that typically have file targets
133
+ FILE_TOOLS = {"Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "NotebookEdit"}
134
+
135
+ # Tools to exclude from activity tracking
136
+ EXCLUDED_TOOLS = {"TodoWrite", "TodoRead"}
137
+
138
+ def __init__(self, cache_ttl: float = 1.0):
139
+ """Initialize parser with optional cache TTL.
140
+
141
+ Args:
142
+ cache_ttl: Cache time-to-live in seconds (default: 1.0)
143
+ """
144
+ self._tool_cache = ParseCache(ttl_seconds=cache_ttl)
145
+ self._todo_cache = ParseCache(ttl_seconds=cache_ttl)
146
+
147
+ def parse(
148
+ self,
149
+ transcript_path: str,
150
+ max_lines: int = 1000,
151
+ from_end: bool = True,
152
+ ) -> ToolActivityState:
153
+ """Parse transcript for tool activity.
154
+
155
+ Args:
156
+ transcript_path: Path to JSONL transcript file
157
+ max_lines: Maximum lines to parse (for performance)
158
+ from_end: Parse from end of file (most recent first)
159
+
160
+ Returns:
161
+ ToolActivityState with running/completed tools
162
+ """
163
+ if not transcript_path or not os.path.exists(transcript_path):
164
+ return ToolActivityState()
165
+
166
+ # Check cache
167
+ cache_key = f"tools:{transcript_path}:{os.path.getmtime(transcript_path)}"
168
+ cached = self._tool_cache.get(cache_key)
169
+ if cached is not None:
170
+ return cached
171
+
172
+ # Parse the transcript
173
+ result = self._parse_tools(transcript_path, max_lines, from_end)
174
+
175
+ # Cache the result
176
+ self._tool_cache.set(cache_key, result)
177
+
178
+ return result
179
+
180
+ def get_todos(self, transcript_path: str) -> TodoState:
181
+ """Extract current todo state from transcript.
182
+
183
+ Args:
184
+ transcript_path: Path to JSONL transcript file
185
+
186
+ Returns:
187
+ TodoState from most recent TodoWrite call
188
+ """
189
+ if not transcript_path or not os.path.exists(transcript_path):
190
+ return TodoState()
191
+
192
+ # Check cache
193
+ cache_key = f"todos:{transcript_path}:{os.path.getmtime(transcript_path)}"
194
+ cached = self._todo_cache.get(cache_key)
195
+ if cached is not None:
196
+ return cached
197
+
198
+ # Parse for todos
199
+ result = self._parse_todos(transcript_path)
200
+
201
+ # Cache the result
202
+ self._todo_cache.set(cache_key, result)
203
+
204
+ return result
205
+
206
+ def _parse_tools(
207
+ self,
208
+ transcript_path: str,
209
+ max_lines: int,
210
+ from_end: bool,
211
+ ) -> ToolActivityState:
212
+ """Internal tool parsing implementation."""
213
+ state = ToolActivityState()
214
+ state.last_updated = datetime.now(timezone.utc).isoformat()
215
+
216
+ # Track tool_use -> tool_result matching
217
+ pending_tools: Dict[str, ToolEntry] = {}
218
+ seen_tool_ids: set = set()
219
+
220
+ try:
221
+ lines = self._read_lines(transcript_path, max_lines, from_end)
222
+
223
+ for line in lines:
224
+ if not line.strip():
225
+ continue
226
+
227
+ try:
228
+ entry = json.loads(line)
229
+ except json.JSONDecodeError:
230
+ continue
231
+
232
+ # Handle tool_use entries
233
+ if entry.get("type") == "tool_use":
234
+ tool_use_id = entry.get("tool_use_id", "")
235
+ name = entry.get("name", "")
236
+
237
+ if name in self.EXCLUDED_TOOLS:
238
+ continue
239
+
240
+ if tool_use_id in seen_tool_ids:
241
+ continue
242
+ seen_tool_ids.add(tool_use_id)
243
+
244
+ tool_input = entry.get("input", {})
245
+ target = self._extract_target(name, tool_input)
246
+ input_preview = self._get_input_preview(name, tool_input)
247
+
248
+ tool_entry = ToolEntry(
249
+ name=name,
250
+ tool_use_id=tool_use_id,
251
+ status="running",
252
+ target=target,
253
+ started_at=entry.get("timestamp"),
254
+ input_preview=input_preview,
255
+ )
256
+ pending_tools[tool_use_id] = tool_entry
257
+
258
+ # Handle tool_result entries
259
+ elif entry.get("type") == "tool_result":
260
+ tool_use_id = entry.get("tool_use_id", "")
261
+
262
+ if tool_use_id in pending_tools:
263
+ tool_entry = pending_tools.pop(tool_use_id)
264
+ tool_entry.status = "completed"
265
+ tool_entry.completed_at = entry.get("timestamp")
266
+
267
+ # Check for errors
268
+ is_error = entry.get("is_error", False)
269
+ if is_error:
270
+ tool_entry.status = "error"
271
+ tool_entry.error = str(entry.get("content", ""))[:100]
272
+ state.error_count += 1
273
+
274
+ # Calculate duration if timestamps available
275
+ if tool_entry.started_at and tool_entry.completed_at:
276
+ try:
277
+ start = datetime.fromisoformat(
278
+ tool_entry.started_at.replace('Z', '+00:00')
279
+ )
280
+ end = datetime.fromisoformat(
281
+ tool_entry.completed_at.replace('Z', '+00:00')
282
+ )
283
+ tool_entry.duration_ms = int(
284
+ (end - start).total_seconds() * 1000
285
+ )
286
+ except (ValueError, TypeError):
287
+ pass
288
+
289
+ state.completed.append(tool_entry)
290
+
291
+ # Update counts
292
+ name = tool_entry.name
293
+ state.tool_counts[name] = state.tool_counts.get(name, 0) + 1
294
+
295
+ # Remaining pending tools are still running
296
+ state.running = list(pending_tools.values())
297
+
298
+ # Limit running tools display (most recent 2)
299
+ state.running = state.running[:2]
300
+
301
+ # Limit completed tools (most recent 10)
302
+ state.completed = state.completed[:10]
303
+
304
+ except Exception:
305
+ # Graceful degradation - return empty state on any error
306
+ state = ToolActivityState()
307
+ state.last_updated = datetime.now(timezone.utc).isoformat()
308
+
309
+ return state
310
+
311
+ def _parse_todos(self, transcript_path: str) -> TodoState:
312
+ """Internal todo parsing implementation."""
313
+ state = TodoState()
314
+
315
+ try:
316
+ # Read from end to find most recent TodoWrite
317
+ lines = self._read_lines(transcript_path, max_lines=500, from_end=True)
318
+
319
+ # Reverse to iterate from most recent first
320
+ for line in reversed(lines):
321
+ if not line.strip():
322
+ continue
323
+
324
+ try:
325
+ entry = json.loads(line)
326
+ except json.JSONDecodeError:
327
+ continue
328
+
329
+ # Look for TodoWrite tool_use entries
330
+ if (entry.get("type") == "tool_use" and
331
+ entry.get("name") == "TodoWrite"):
332
+
333
+ tool_input = entry.get("input", {})
334
+ todos = tool_input.get("todos", [])
335
+
336
+ if not todos:
337
+ continue
338
+
339
+ state.items = []
340
+ state.total = len(todos)
341
+ state.completed = 0
342
+ state.in_progress = None
343
+ state.timestamp = entry.get("timestamp")
344
+
345
+ for todo in todos:
346
+ item = TodoItem(
347
+ content=todo.get("content", ""),
348
+ status=todo.get("status", "pending"),
349
+ active_form=todo.get("activeForm"),
350
+ )
351
+ state.items.append(item)
352
+
353
+ if item.status == "completed":
354
+ state.completed += 1
355
+ elif item.status == "in_progress":
356
+ state.in_progress = item
357
+
358
+ # Found the most recent TodoWrite, stop
359
+ break
360
+
361
+ except Exception:
362
+ # Graceful degradation
363
+ state = TodoState()
364
+
365
+ return state
366
+
367
+ def _extract_target(self, tool_name: str, tool_input: Dict[str, Any]) -> Optional[str]:
368
+ """Extract file/path target from tool input.
369
+
370
+ Args:
371
+ tool_name: Name of the tool
372
+ tool_input: Tool input parameters
373
+
374
+ Returns:
375
+ Extracted target path or pattern, or None
376
+ """
377
+ if tool_name not in self.FILE_TOOLS:
378
+ return None
379
+
380
+ # Try common field names for file paths
381
+ for field_name in ["file_path", "path", "pattern", "notebook_path"]:
382
+ value = tool_input.get(field_name)
383
+ if value:
384
+ return self._shorten_path(str(value))
385
+
386
+ return None
387
+
388
+ def _shorten_path(self, path: str, max_len: int = 40) -> str:
389
+ """Shorten a path for display.
390
+
391
+ Args:
392
+ path: Full path
393
+ max_len: Maximum length
394
+
395
+ Returns:
396
+ Shortened path with ... if needed
397
+ """
398
+ if len(path) <= max_len:
399
+ return path
400
+
401
+ # Try to show filename with partial path
402
+ parts = path.split("/")
403
+ if len(parts) >= 2:
404
+ filename = parts[-1]
405
+ parent = parts[-2]
406
+ short = f".../{parent}/{filename}"
407
+ if len(short) <= max_len:
408
+ return short
409
+ return f".../{filename}"[:max_len]
410
+
411
+ return path[:max_len - 3] + "..."
412
+
413
+ def _get_input_preview(
414
+ self,
415
+ tool_name: str,
416
+ tool_input: Dict[str, Any],
417
+ max_len: int = 50,
418
+ ) -> Optional[str]:
419
+ """Get a preview of tool input for display.
420
+
421
+ Args:
422
+ tool_name: Name of the tool
423
+ tool_input: Tool input parameters
424
+ max_len: Maximum preview length
425
+
426
+ Returns:
427
+ Input preview string or None
428
+ """
429
+ # For file tools, show the target
430
+ target = self._extract_target(tool_name, tool_input)
431
+ if target:
432
+ return target
433
+
434
+ # For Bash, show command
435
+ if tool_name == "Bash":
436
+ cmd = tool_input.get("command", "")
437
+ if len(cmd) > max_len:
438
+ return cmd[:max_len - 3] + "..."
439
+ return cmd
440
+
441
+ # For WebFetch, show URL
442
+ if tool_name == "WebFetch":
443
+ url = tool_input.get("url", "")
444
+ if len(url) > max_len:
445
+ return url[:max_len - 3] + "..."
446
+ return url
447
+
448
+ return None
449
+
450
+ def _read_lines(
451
+ self,
452
+ filepath: str,
453
+ max_lines: int,
454
+ from_end: bool,
455
+ ) -> List[str]:
456
+ """Read lines from file, optionally from end.
457
+
458
+ Args:
459
+ filepath: Path to file
460
+ max_lines: Maximum lines to read
461
+ from_end: Read from end of file
462
+
463
+ Returns:
464
+ List of lines
465
+ """
466
+ try:
467
+ with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
468
+ if from_end:
469
+ # Read all lines and take last N
470
+ # For very large files, we could use seek + readline
471
+ lines = f.readlines()
472
+ return lines[-max_lines:]
473
+ else:
474
+ lines = []
475
+ for i, line in enumerate(f):
476
+ if i >= max_lines:
477
+ break
478
+ lines.append(line)
479
+ return lines
480
+ except Exception:
481
+ return []
482
+
483
+
484
+ # =============================================================================
485
+ # Convenience Functions
486
+ # =============================================================================
487
+
488
+
489
+ _parser: Optional[TranscriptParser] = None
490
+
491
+
492
+ def get_parser(cache_ttl: float = 1.0) -> TranscriptParser:
493
+ """Get singleton parser instance."""
494
+ global _parser
495
+ if _parser is None:
496
+ _parser = TranscriptParser(cache_ttl=cache_ttl)
497
+ return _parser
498
+
499
+
500
+ def parse_transcript(transcript_path: str) -> ToolActivityState:
501
+ """Parse transcript for tool activity (convenience function)."""
502
+ return get_parser().parse(transcript_path)
503
+
504
+
505
+ def get_todos_from_transcript(transcript_path: str) -> TodoState:
506
+ """Get todos from transcript (convenience function)."""
507
+ return get_parser().get_todos(transcript_path)
508
+
509
+
510
+ # =============================================================================
511
+ # CLI Interface
512
+ # =============================================================================
513
+
514
+
515
+ if __name__ == "__main__":
516
+ import sys
517
+
518
+ if len(sys.argv) < 2:
519
+ print("Usage: transcript_parser.py <command> [transcript_path]")
520
+ print("Commands:")
521
+ print(" tools <path> - Parse tool activity")
522
+ print(" todos <path> - Parse todo state")
523
+ print(" json <path> - Output both as JSON")
524
+ sys.exit(1)
525
+
526
+ command = sys.argv[1]
527
+ transcript_path = sys.argv[2] if len(sys.argv) > 2 else None
528
+
529
+ parser = TranscriptParser()
530
+
531
+ if command == "tools":
532
+ if not transcript_path:
533
+ print("Error: transcript path required")
534
+ sys.exit(1)
535
+
536
+ activity = parser.parse(transcript_path)
537
+ print(f"Running tools: {len(activity.running)}")
538
+ for tool in activity.running:
539
+ print(f" - {tool.name}: {tool.target or 'N/A'}")
540
+
541
+ print(f"\nCompleted tools: {len(activity.completed)}")
542
+ for name, count in activity.get_top_completed():
543
+ print(f" - {name}: {count}x")
544
+
545
+ print(f"\nErrors: {activity.error_count}")
546
+
547
+ elif command == "todos":
548
+ if not transcript_path:
549
+ print("Error: transcript path required")
550
+ sys.exit(1)
551
+
552
+ todos = parser.get_todos(transcript_path)
553
+ print(f"Total: {todos.total}")
554
+ print(f"Completed: {todos.completed}")
555
+ print(f"Pending: {todos.pending}")
556
+
557
+ if todos.in_progress:
558
+ print(f"\nIn Progress: {todos.in_progress.content}")
559
+ if todos.in_progress.active_form:
560
+ print(f" Active Form: {todos.in_progress.active_form}")
561
+
562
+ elif command == "json":
563
+ if not transcript_path:
564
+ print("Error: transcript path required")
565
+ sys.exit(1)
566
+
567
+ activity = parser.parse(transcript_path)
568
+ todos = parser.get_todos(transcript_path)
569
+
570
+ output = {
571
+ "tools": {
572
+ "running": [
573
+ {
574
+ "name": t.name,
575
+ "target": t.target,
576
+ "started_at": t.started_at,
577
+ }
578
+ for t in activity.running
579
+ ],
580
+ "completed_counts": dict(activity.get_top_completed()),
581
+ "error_count": activity.error_count,
582
+ },
583
+ "todos": {
584
+ "total": todos.total,
585
+ "completed": todos.completed,
586
+ "pending": todos.pending,
587
+ "in_progress": {
588
+ "content": todos.in_progress.content,
589
+ "active_form": todos.in_progress.active_form,
590
+ } if todos.in_progress else None,
591
+ },
592
+ }
593
+ print(json.dumps(output, indent=2))
594
+
595
+ else:
596
+ print(f"Unknown command: {command}")
597
+ sys.exit(1)