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.
- package/README.md +719 -0
- package/VERSION +1 -0
- package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
- package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
- package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
- package/docs/INSTALLATION.md +984 -0
- package/docs/anvil-hud.md +469 -0
- package/docs/anvil-init.md +255 -0
- package/docs/anvil-state.md +210 -0
- package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
- package/docs/command-reference.md +2022 -0
- package/docs/hooks-tts.md +368 -0
- package/docs/implementation-guide.md +810 -0
- package/docs/linear-github-integration.md +247 -0
- package/docs/local-issues.md +677 -0
- package/docs/patterns/README.md +419 -0
- package/docs/planning-responsibilities.md +139 -0
- package/docs/session-workflow.md +573 -0
- package/docs/simplification-plan-template.md +297 -0
- package/docs/simplification-principles.md +129 -0
- package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
- package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
- package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
- package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
- package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
- package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
- package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
- package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
- package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
- package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
- package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
- package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
- package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
- package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
- package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
- package/docs/sync.md +122 -0
- package/global/CLAUDE.md +140 -0
- package/global/agents/verify-app.md +164 -0
- package/global/commands/anvil-settings.md +527 -0
- package/global/commands/anvil-sync.md +121 -0
- package/global/commands/change.md +197 -0
- package/global/commands/clarify.md +252 -0
- package/global/commands/cleanup.md +292 -0
- package/global/commands/commit-push-pr.md +207 -0
- package/global/commands/decay-review.md +127 -0
- package/global/commands/discover.md +158 -0
- package/global/commands/doc-coverage.md +122 -0
- package/global/commands/evidence.md +307 -0
- package/global/commands/explore.md +121 -0
- package/global/commands/force-exit.md +135 -0
- package/global/commands/handoff.md +191 -0
- package/global/commands/healthcheck.md +302 -0
- package/global/commands/hud.md +84 -0
- package/global/commands/insights.md +319 -0
- package/global/commands/linear-setup.md +184 -0
- package/global/commands/lint-fix.md +198 -0
- package/global/commands/orient.md +510 -0
- package/global/commands/plan.md +228 -0
- package/global/commands/ralph.md +346 -0
- package/global/commands/ready.md +182 -0
- package/global/commands/release.md +305 -0
- package/global/commands/retro.md +96 -0
- package/global/commands/shard.md +166 -0
- package/global/commands/spec.md +227 -0
- package/global/commands/sprint.md +184 -0
- package/global/commands/tasks.md +228 -0
- package/global/commands/test-and-commit.md +151 -0
- package/global/commands/validate.md +132 -0
- package/global/commands/verify.md +251 -0
- package/global/commands/weekly-review.md +156 -0
- package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
- package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
- package/global/hooks/anvil_memory_observe.ts +322 -0
- package/global/hooks/anvil_memory_session.ts +166 -0
- package/global/hooks/anvil_memory_stop.ts +187 -0
- package/global/hooks/parse_transcript.py +116 -0
- package/global/hooks/post_merge_cleanup.sh +132 -0
- package/global/hooks/post_tool_format.sh +215 -0
- package/global/hooks/ralph_context_monitor.py +240 -0
- package/global/hooks/ralph_stop.sh +502 -0
- package/global/hooks/statusline.sh +1110 -0
- package/global/hooks/statusline_agent_sync.py +224 -0
- package/global/hooks/stop_gate.sh +250 -0
- package/global/lib/.claude/anvil-state.json +21 -0
- package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
- package/global/lib/agent_registry.py +995 -0
- package/global/lib/anvil-state.sh +435 -0
- package/global/lib/claim_service.py +515 -0
- package/global/lib/coderabbit_service.py +314 -0
- package/global/lib/config_service.py +423 -0
- package/global/lib/coordination_service.py +331 -0
- package/global/lib/doc_coverage_service.py +1305 -0
- package/global/lib/gate_logger.py +316 -0
- package/global/lib/github_service.py +310 -0
- package/global/lib/handoff_generator.py +775 -0
- package/global/lib/hygiene_service.py +712 -0
- package/global/lib/issue_models.py +257 -0
- package/global/lib/issue_provider.py +339 -0
- package/global/lib/linear_data_service.py +210 -0
- package/global/lib/linear_provider.py +987 -0
- package/global/lib/linear_provider.py.backup +671 -0
- package/global/lib/local_provider.py +486 -0
- package/global/lib/orient_fast.py +457 -0
- package/global/lib/quality_service.py +470 -0
- package/global/lib/ralph_prompt_generator.py +563 -0
- package/global/lib/ralph_state.py +1202 -0
- package/global/lib/state_manager.py +417 -0
- package/global/lib/transcript_parser.py +597 -0
- package/global/lib/verification_runner.py +557 -0
- package/global/lib/verify_iteration.py +490 -0
- package/global/lib/verify_subagent.py +250 -0
- package/global/skills/README.md +155 -0
- package/global/skills/quality-gates/SKILL.md +252 -0
- package/global/skills/skill-template/SKILL.md +109 -0
- package/global/skills/testing-strategies/SKILL.md +337 -0
- package/global/templates/CHANGE-template.md +105 -0
- package/global/templates/HANDOFF-template.md +63 -0
- package/global/templates/PLAN-template.md +111 -0
- package/global/templates/SPEC-template.md +93 -0
- package/global/templates/ralph/PROMPT.md.template +89 -0
- package/global/templates/ralph/fix_plan.md.template +31 -0
- package/global/templates/ralph/progress.txt.template +23 -0
- package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
- package/global/tests/test_doc_coverage.py +520 -0
- package/global/tests/test_issue_models.py +299 -0
- package/global/tests/test_local_provider.py +323 -0
- package/global/tools/README.md +178 -0
- package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
- package/global/tools/anvil-hud.py +3622 -0
- package/global/tools/anvil-hud.py.bak +3318 -0
- package/global/tools/anvil-issue.py +432 -0
- package/global/tools/anvil-memory/CLAUDE.md +49 -0
- package/global/tools/anvil-memory/README.md +42 -0
- package/global/tools/anvil-memory/bun.lock +25 -0
- package/global/tools/anvil-memory/bunfig.toml +9 -0
- package/global/tools/anvil-memory/package.json +23 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
- package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
- package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
- package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
- package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
- package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
- package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
- package/global/tools/anvil-memory/src/commands/get.ts +115 -0
- package/global/tools/anvil-memory/src/commands/init.ts +94 -0
- package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
- package/global/tools/anvil-memory/src/commands/search.ts +112 -0
- package/global/tools/anvil-memory/src/db.ts +638 -0
- package/global/tools/anvil-memory/src/index.ts +205 -0
- package/global/tools/anvil-memory/src/types.ts +122 -0
- package/global/tools/anvil-memory/tsconfig.json +29 -0
- package/global/tools/ralph-loop.sh +359 -0
- package/package.json +45 -0
- package/scripts/anvil +822 -0
- package/scripts/extract_patterns.py +222 -0
- package/scripts/init-project.sh +541 -0
- package/scripts/install.sh +229 -0
- package/scripts/postinstall.js +41 -0
- package/scripts/rollback.sh +188 -0
- package/scripts/sync.sh +623 -0
- package/scripts/test-statusline.sh +248 -0
- package/scripts/update_claude_md.py +224 -0
- 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)
|