htmlgraph 0.24.2__py3-none-any.whl → 0.26.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- htmlgraph/__init__.py +20 -1
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/analytics/cross_session.py +4 -3
- htmlgraph/analytics/work_type.py +52 -16
- htmlgraph/analytics_index.py +51 -19
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/main.py +2263 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +812 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1020 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +509 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +163 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/builders/base.py +55 -1
- htmlgraph/builders/bug.py +17 -2
- htmlgraph/builders/chore.py +17 -2
- htmlgraph/builders/epic.py +17 -2
- htmlgraph/builders/feature.py +25 -2
- htmlgraph/builders/phase.py +17 -2
- htmlgraph/builders/spike.py +27 -2
- htmlgraph/builders/track.py +14 -0
- htmlgraph/cigs/__init__.py +4 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cli.py +1427 -401
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +2 -0
- htmlgraph/collections/base.py +21 -0
- htmlgraph/collections/session.py +189 -0
- htmlgraph/collections/spike.py +7 -1
- htmlgraph/collections/task_delegation.py +236 -0
- htmlgraph/collections/traces.py +482 -0
- htmlgraph/config.py +113 -0
- htmlgraph/converter.py +41 -0
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +3356 -492
- htmlgraph-0.24.2.data/data/htmlgraph/dashboard.html → htmlgraph/dashboard.html.backup +2246 -248
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1584 -0
- htmlgraph/deploy.py +26 -27
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +710 -0
- htmlgraph/docs/README.md +533 -0
- htmlgraph/docs/version_check.py +3 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +2 -0
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +2 -2
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +318 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +496 -79
- htmlgraph/hooks/orchestrator.py +6 -4
- htmlgraph/hooks/orchestrator_reflector.py +4 -4
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/pretooluse.py +473 -6
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +637 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_stop.py +309 -0
- htmlgraph/hooks/task_enforcer.py +39 -0
- htmlgraph/hooks/validator.py +15 -11
- htmlgraph/models.py +111 -15
- htmlgraph/operations/fastapi_server.py +230 -0
- htmlgraph/orchestration/headless_spawner.py +344 -29
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/repo_hash.py +511 -0
- htmlgraph/sdk.py +348 -10
- htmlgraph/server.py +194 -0
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +131 -1
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/system_prompts.py +449 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +19 -0
- htmlgraph/validation.py +115 -0
- htmlgraph-0.26.1.data/data/htmlgraph/dashboard.html +7458 -0
- {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/METADATA +91 -64
- {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/RECORD +112 -46
- {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.24.2.data → htmlgraph-0.26.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/WHEEL +0 -0
- {htmlgraph-0.24.2.dist-info → htmlgraph-0.26.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SubagentStop Hook - Update parent events when subagents complete.
|
|
3
|
+
|
|
4
|
+
This module handles the SubagentStop hook event, which fires when a subagent
|
|
5
|
+
(spawned via Task()) completes. It updates the parent event with completion
|
|
6
|
+
status and counts child spikes created during the subagent's execution.
|
|
7
|
+
|
|
8
|
+
Architecture:
|
|
9
|
+
- Reads HTMLGRAPH_PARENT_EVENT from environment (set by PreToolUse hook)
|
|
10
|
+
- Queries database for spikes created since parent event start
|
|
11
|
+
- Updates parent event: status="completed", child_spike_count=N
|
|
12
|
+
- Handles graceful degradation if parent event not found
|
|
13
|
+
|
|
14
|
+
Parent-Child Event Nesting:
|
|
15
|
+
- Parent: evt-abc (Task delegation) created by PreToolUse
|
|
16
|
+
- Child events: spikes created by subagent during task execution
|
|
17
|
+
- Result: Full trace of delegation work visible in dashboard
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import sqlite3
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_parent_event_id() -> str | None:
|
|
33
|
+
"""
|
|
34
|
+
Get the parent event ID from environment.
|
|
35
|
+
|
|
36
|
+
Set by PreToolUse hook when Task() is detected.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Parent event ID (evt-XXXXX) or None if not found
|
|
40
|
+
"""
|
|
41
|
+
return os.environ.get("HTMLGRAPH_PARENT_EVENT")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_session_id() -> str | None:
|
|
45
|
+
"""
|
|
46
|
+
Get the current session ID from environment.
|
|
47
|
+
|
|
48
|
+
Set by SessionStart hook.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Session ID or None if not found
|
|
52
|
+
"""
|
|
53
|
+
return os.environ.get("HTMLGRAPH_SESSION_ID")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def count_child_spikes(
|
|
57
|
+
db_path: str, parent_event_id: str, parent_start_time: str
|
|
58
|
+
) -> int:
|
|
59
|
+
"""
|
|
60
|
+
Count spikes created after the parent event started.
|
|
61
|
+
|
|
62
|
+
Queries the features table for spikes with created_at > parent start time.
|
|
63
|
+
Uses a narrow time window (5 minutes) to avoid counting unrelated spikes
|
|
64
|
+
from other sessions.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
db_path: Path to SQLite database
|
|
68
|
+
parent_event_id: Parent event ID
|
|
69
|
+
parent_start_time: ISO8601 timestamp when parent event started
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Count of child spikes (0 if none found)
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
conn = sqlite3.connect(db_path)
|
|
76
|
+
cursor = conn.cursor()
|
|
77
|
+
|
|
78
|
+
# Validate parent start time format (ISO8601)
|
|
79
|
+
try:
|
|
80
|
+
datetime.fromisoformat(parent_start_time)
|
|
81
|
+
except (ValueError, TypeError):
|
|
82
|
+
# If parsing fails, return 0 (couldn't validate time window)
|
|
83
|
+
logger.warning(f"Could not parse parent start time: {parent_start_time}")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
# Query spikes created within 5 minutes after parent event
|
|
87
|
+
# This avoids counting unrelated spikes from other sessions
|
|
88
|
+
query = """
|
|
89
|
+
SELECT COUNT(*) FROM features
|
|
90
|
+
WHERE type = 'spike'
|
|
91
|
+
AND created_at >= ?
|
|
92
|
+
AND created_at <= datetime(?, '+5 minutes')
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
cursor.execute(query, (parent_start_time, parent_start_time))
|
|
96
|
+
result = cursor.fetchone()
|
|
97
|
+
count = result[0] if result else 0
|
|
98
|
+
|
|
99
|
+
conn.close()
|
|
100
|
+
logger.debug(f"Found {count} child spikes for parent event {parent_event_id}")
|
|
101
|
+
return count
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(f"Error counting child spikes: {e}")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def update_parent_event(
|
|
109
|
+
db_path: str,
|
|
110
|
+
parent_event_id: str,
|
|
111
|
+
child_spike_count: int,
|
|
112
|
+
completion_time: str | None = None,
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Update parent event with completion status and child spike count.
|
|
116
|
+
|
|
117
|
+
Updates agent_events table:
|
|
118
|
+
- status: "started" → "completed"
|
|
119
|
+
- child_spike_count: Count of spikes created by subagent
|
|
120
|
+
- output_summary: JSON with completion info
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
db_path: Path to SQLite database
|
|
124
|
+
parent_event_id: Parent event ID to update
|
|
125
|
+
child_spike_count: Number of child spikes created
|
|
126
|
+
completion_time: ISO8601 timestamp (optional, defaults to now)
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
True if update successful, False otherwise
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
if completion_time is None:
|
|
133
|
+
completion_time = datetime.now(timezone.utc).isoformat()
|
|
134
|
+
|
|
135
|
+
conn = sqlite3.connect(db_path)
|
|
136
|
+
cursor = conn.cursor()
|
|
137
|
+
|
|
138
|
+
# Build output summary
|
|
139
|
+
output_summary = json.dumps(
|
|
140
|
+
{
|
|
141
|
+
"status": "completed",
|
|
142
|
+
"child_spike_count": child_spike_count,
|
|
143
|
+
"completion_time": completion_time,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Update parent event
|
|
148
|
+
query = """
|
|
149
|
+
UPDATE agent_events
|
|
150
|
+
SET status = ?, child_spike_count = ?, output_summary = ?, updated_at = CURRENT_TIMESTAMP
|
|
151
|
+
WHERE event_id = ?
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
cursor.execute(
|
|
155
|
+
query,
|
|
156
|
+
("completed", child_spike_count, output_summary, parent_event_id),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if cursor.rowcount == 0:
|
|
160
|
+
logger.warning(f"Parent event not found: {parent_event_id}")
|
|
161
|
+
conn.close()
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
conn.commit()
|
|
165
|
+
conn.close()
|
|
166
|
+
|
|
167
|
+
logger.info(
|
|
168
|
+
f"Updated parent event {parent_event_id}: "
|
|
169
|
+
f"status=completed, child_spike_count={child_spike_count}"
|
|
170
|
+
)
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.warning(f"Error updating parent event: {e}")
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_parent_event_start_time(db_path: str, parent_event_id: str) -> str | None:
|
|
179
|
+
"""
|
|
180
|
+
Get the start time of the parent event.
|
|
181
|
+
|
|
182
|
+
Used to set the time window for counting child spikes.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
db_path: Path to SQLite database
|
|
186
|
+
parent_event_id: Parent event ID
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
ISO8601 timestamp or None if not found
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
conn = sqlite3.connect(db_path)
|
|
193
|
+
cursor = conn.cursor()
|
|
194
|
+
|
|
195
|
+
query = "SELECT timestamp FROM agent_events WHERE event_id = ?"
|
|
196
|
+
cursor.execute(query, (parent_event_id,))
|
|
197
|
+
result = cursor.fetchone()
|
|
198
|
+
|
|
199
|
+
conn.close()
|
|
200
|
+
return result[0] if result else None
|
|
201
|
+
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.warning(f"Error getting parent event start time: {e}")
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
208
|
+
"""
|
|
209
|
+
Handle SubagentStop hook event.
|
|
210
|
+
|
|
211
|
+
When a subagent completes, updates the parent event with:
|
|
212
|
+
1. Completion status
|
|
213
|
+
2. Count of spikes created during subagent execution
|
|
214
|
+
3. Completion timestamp
|
|
215
|
+
|
|
216
|
+
This closes the parent-child event trace and enables dashboard visualization
|
|
217
|
+
of the complete delegation hierarchy.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
hook_input: Hook input data from Claude Code
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Response: {"continue": True} with optional context
|
|
224
|
+
"""
|
|
225
|
+
# Get parent event ID from environment
|
|
226
|
+
parent_event_id = get_parent_event_id()
|
|
227
|
+
|
|
228
|
+
if not parent_event_id:
|
|
229
|
+
logger.debug("No parent event ID found, skipping subagent stop tracking")
|
|
230
|
+
return {"continue": True}
|
|
231
|
+
|
|
232
|
+
# Get project directory and database path
|
|
233
|
+
try:
|
|
234
|
+
cwd = hook_input.get("cwd", os.getcwd())
|
|
235
|
+
graph_dir = Path(cwd) / ".htmlgraph"
|
|
236
|
+
db_path = str(graph_dir / "index.sqlite")
|
|
237
|
+
|
|
238
|
+
if not Path(db_path).exists():
|
|
239
|
+
logger.warning(f"Database not found: {db_path}")
|
|
240
|
+
return {"continue": True}
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.warning(f"Error resolving database path: {e}")
|
|
244
|
+
return {"continue": True}
|
|
245
|
+
|
|
246
|
+
# Get parent event start time
|
|
247
|
+
parent_start_time = get_parent_event_start_time(db_path, parent_event_id)
|
|
248
|
+
if not parent_start_time:
|
|
249
|
+
logger.warning(f"Could not find parent event: {parent_event_id}")
|
|
250
|
+
return {"continue": True}
|
|
251
|
+
|
|
252
|
+
# Count child spikes
|
|
253
|
+
child_spike_count = count_child_spikes(db_path, parent_event_id, parent_start_time)
|
|
254
|
+
|
|
255
|
+
# Update parent event with completion info
|
|
256
|
+
completion_time = datetime.now(timezone.utc).isoformat()
|
|
257
|
+
success = update_parent_event(
|
|
258
|
+
db_path,
|
|
259
|
+
parent_event_id,
|
|
260
|
+
child_spike_count,
|
|
261
|
+
completion_time,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if success:
|
|
265
|
+
# Clear parent event from environment
|
|
266
|
+
os.environ.pop("HTMLGRAPH_PARENT_EVENT", None)
|
|
267
|
+
os.environ.pop("HTMLGRAPH_SUBAGENT_TYPE", None)
|
|
268
|
+
|
|
269
|
+
logger.info(
|
|
270
|
+
f"Subagent stop recorded: parent_event={parent_event_id}, "
|
|
271
|
+
f"child_spikes={child_spike_count}"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"continue": True,
|
|
276
|
+
"hookSpecificOutput": {
|
|
277
|
+
"hookEventName": "SubagentStop",
|
|
278
|
+
"additionalContext": (
|
|
279
|
+
f"Task delegation completed: {child_spike_count} spike(s) created"
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {"continue": True}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main() -> None:
|
|
288
|
+
"""Hook entry point for script wrapper."""
|
|
289
|
+
# Check if tracking is disabled
|
|
290
|
+
if os.environ.get("HTMLGRAPH_DISABLE_TRACKING") == "1":
|
|
291
|
+
print(json.dumps({"continue": True}))
|
|
292
|
+
sys.exit(0)
|
|
293
|
+
|
|
294
|
+
# Read hook input from stdin
|
|
295
|
+
try:
|
|
296
|
+
hook_input = json.load(sys.stdin)
|
|
297
|
+
except json.JSONDecodeError:
|
|
298
|
+
hook_input = {}
|
|
299
|
+
|
|
300
|
+
# Handle subagent stop
|
|
301
|
+
result = handle_subagent_stop(hook_input)
|
|
302
|
+
|
|
303
|
+
# Output response
|
|
304
|
+
print(json.dumps(result))
|
|
305
|
+
sys.exit(0)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
if __name__ == "__main__":
|
|
309
|
+
main()
|
htmlgraph/hooks/task_enforcer.py
CHANGED
|
@@ -130,6 +130,45 @@ def enforce_task_saving(tool_name: str, tool_params: dict[str, Any]) -> dict[str
|
|
|
130
130
|
parent_session = os.environ.get("HTMLGRAPH_PARENT_SESSION")
|
|
131
131
|
parent_agent = os.environ.get("HTMLGRAPH_PARENT_AGENT", "claude-code")
|
|
132
132
|
nesting_depth = int(os.environ.get("HTMLGRAPH_NESTING_DEPTH", "0"))
|
|
133
|
+
current_session = os.environ.get("HTMLGRAPH_SESSION_ID", "")
|
|
134
|
+
|
|
135
|
+
# Record delegation event in database
|
|
136
|
+
try:
|
|
137
|
+
import uuid
|
|
138
|
+
|
|
139
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
140
|
+
|
|
141
|
+
db = HtmlGraphDB()
|
|
142
|
+
db.connect()
|
|
143
|
+
|
|
144
|
+
# Extract to_agent from subagent_type (e.g., "haiku" -> "haiku", "gemini-spawner" -> "gemini-spawner")
|
|
145
|
+
to_agent = tool_params.get("subagent_type", "unknown")
|
|
146
|
+
task_description = tool_params.get("description", "Unnamed task")
|
|
147
|
+
|
|
148
|
+
# Determine session ID, using current > parent > auto-generated
|
|
149
|
+
session_id = (
|
|
150
|
+
current_session or parent_session or f"session-{uuid.uuid4().hex[:8]}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Ensure session exists before recording delegation (handles FK constraints)
|
|
154
|
+
db._ensure_session_exists(session_id, parent_agent)
|
|
155
|
+
|
|
156
|
+
# Record the delegation event
|
|
157
|
+
db.record_delegation_event(
|
|
158
|
+
from_agent=parent_agent,
|
|
159
|
+
to_agent=to_agent,
|
|
160
|
+
task_description=task_description,
|
|
161
|
+
session_id=session_id,
|
|
162
|
+
context={
|
|
163
|
+
"nesting_depth": nesting_depth,
|
|
164
|
+
"prompt_preview": prompt[:200] if prompt else "",
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
db.close()
|
|
169
|
+
except Exception:
|
|
170
|
+
# Graceful degradation - continue even if delegation tracking fails
|
|
171
|
+
pass
|
|
133
172
|
|
|
134
173
|
# Track Task invocation as activity (if parent session exists)
|
|
135
174
|
task_activity_id = None
|
htmlgraph/hooks/validator.py
CHANGED
|
@@ -79,7 +79,7 @@ def load_tool_history() -> list[dict]:
|
|
|
79
79
|
data = json.loads(TOOL_HISTORY_FILE.read_text())
|
|
80
80
|
|
|
81
81
|
# Handle both formats: {"history": [...]} and [...] (legacy)
|
|
82
|
-
if isinstance(data, dict):
|
|
82
|
+
if isinstance(data, dict): # type: ignore[arg-type]
|
|
83
83
|
data = data.get("history", [])
|
|
84
84
|
|
|
85
85
|
# Filter to last hour only
|
|
@@ -149,7 +149,7 @@ def detect_optimal_pattern(tool: str, history: list[dict]) -> str | None:
|
|
|
149
149
|
return OPTIMAL_PATTERNS.get(pair)
|
|
150
150
|
|
|
151
151
|
|
|
152
|
-
def get_pattern_guidance(tool: str, history: list[dict]) -> dict:
|
|
152
|
+
def get_pattern_guidance(tool: str, history: list[dict]) -> dict[str, Any]:
|
|
153
153
|
"""Get guidance based on tool patterns."""
|
|
154
154
|
# Check for anti-patterns first
|
|
155
155
|
anti_pattern = detect_anti_pattern(tool, history)
|
|
@@ -192,7 +192,7 @@ def get_session_health_hint(history: list[dict]) -> str | None:
|
|
|
192
192
|
return None
|
|
193
193
|
|
|
194
194
|
|
|
195
|
-
def load_validation_config() -> dict:
|
|
195
|
+
def load_validation_config() -> dict[str, Any]:
|
|
196
196
|
"""Load validation config with defaults."""
|
|
197
197
|
config_path = (
|
|
198
198
|
Path(__file__).parent.parent.parent.parent.parent
|
|
@@ -218,7 +218,9 @@ def load_validation_config() -> dict:
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
|
|
221
|
-
def is_always_allowed(
|
|
221
|
+
def is_always_allowed(
|
|
222
|
+
tool: str, params: dict[str, Any], config: dict[str, Any]
|
|
223
|
+
) -> bool:
|
|
222
224
|
"""Check if tool is always allowed (read-only operations)."""
|
|
223
225
|
# Always-allow tools
|
|
224
226
|
if tool in config.get("always_allow", {}).get("tools", []):
|
|
@@ -234,7 +236,7 @@ def is_always_allowed(tool: str, params: dict, config: dict) -> bool:
|
|
|
234
236
|
return False
|
|
235
237
|
|
|
236
238
|
|
|
237
|
-
def is_direct_htmlgraph_write(tool: str, params: dict) -> tuple[bool, str]:
|
|
239
|
+
def is_direct_htmlgraph_write(tool: str, params: dict[str, Any]) -> tuple[bool, str]:
|
|
238
240
|
"""Check if attempting direct write to .htmlgraph/ (always denied)."""
|
|
239
241
|
if tool not in ["Write", "Edit", "Delete", "NotebookEdit"]:
|
|
240
242
|
return False, ""
|
|
@@ -246,7 +248,7 @@ def is_direct_htmlgraph_write(tool: str, params: dict) -> tuple[bool, str]:
|
|
|
246
248
|
return False, ""
|
|
247
249
|
|
|
248
250
|
|
|
249
|
-
def is_sdk_command(tool: str, params: dict, config: dict) -> bool:
|
|
251
|
+
def is_sdk_command(tool: str, params: dict[str, Any], config: dict[str, Any]) -> bool:
|
|
250
252
|
"""Check if Bash command is an SDK command."""
|
|
251
253
|
if tool != "Bash":
|
|
252
254
|
return False
|
|
@@ -259,7 +261,9 @@ def is_sdk_command(tool: str, params: dict, config: dict) -> bool:
|
|
|
259
261
|
return False
|
|
260
262
|
|
|
261
263
|
|
|
262
|
-
def is_code_operation(
|
|
264
|
+
def is_code_operation(
|
|
265
|
+
tool: str, params: dict[str, Any], config: dict[str, Any]
|
|
266
|
+
) -> bool:
|
|
263
267
|
"""Check if operation modifies code."""
|
|
264
268
|
# Direct file operations
|
|
265
269
|
if tool in config.get("code_operations", {}).get("tools", []):
|
|
@@ -288,7 +292,7 @@ def get_active_work_item() -> dict | None:
|
|
|
288
292
|
return None
|
|
289
293
|
|
|
290
294
|
|
|
291
|
-
def check_orchestrator_violation(tool: str, params: dict) -> dict | None:
|
|
295
|
+
def check_orchestrator_violation(tool: str, params: dict[str, Any]) -> dict | None:
|
|
292
296
|
"""
|
|
293
297
|
Check if operation violates orchestrator mode rules.
|
|
294
298
|
|
|
@@ -363,8 +367,8 @@ def check_orchestrator_violation(tool: str, params: dict) -> dict | None:
|
|
|
363
367
|
|
|
364
368
|
|
|
365
369
|
def validate_tool_call(
|
|
366
|
-
tool: str, params: dict, config: dict, history: list[dict]
|
|
367
|
-
) -> dict:
|
|
370
|
+
tool: str, params: dict[str, Any], config: dict[str, Any], history: list[dict]
|
|
371
|
+
) -> dict[str, Any]:
|
|
368
372
|
"""
|
|
369
373
|
Validate tool call and return GUIDANCE with active learning.
|
|
370
374
|
|
|
@@ -375,7 +379,7 @@ def validate_tool_call(
|
|
|
375
379
|
history: Tool usage history (from load_tool_history())
|
|
376
380
|
|
|
377
381
|
Returns:
|
|
378
|
-
dict: {"decision": "allow" | "block", "guidance": "...", "suggestion": "...", ...}
|
|
382
|
+
dict[str, Any]: {"decision": "allow" | "block", "guidance": "...", "suggestion": "...", ...}
|
|
379
383
|
All operations are ALLOWED unless blocked for safety reasons.
|
|
380
384
|
|
|
381
385
|
Example:
|
htmlgraph/models.py
CHANGED
|
@@ -7,7 +7,7 @@ These models provide:
|
|
|
7
7
|
- Lightweight context generation for AI agents
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from datetime import datetime
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
11
|
from enum import Enum
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, Literal
|
|
@@ -15,6 +15,11 @@ from typing import Any, Literal
|
|
|
15
15
|
from pydantic import BaseModel, Field
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def utc_now() -> datetime:
|
|
19
|
+
"""Return current time as UTC-aware datetime."""
|
|
20
|
+
return datetime.now(timezone.utc)
|
|
21
|
+
|
|
22
|
+
|
|
18
23
|
class WorkType(str, Enum):
|
|
19
24
|
"""
|
|
20
25
|
Classification of work/activity type for events and sessions.
|
|
@@ -264,15 +269,15 @@ class Node(BaseModel):
|
|
|
264
269
|
if edge.relationship not in self.edges:
|
|
265
270
|
self.edges[edge.relationship] = []
|
|
266
271
|
self.edges[edge.relationship].append(edge)
|
|
267
|
-
self.updated =
|
|
272
|
+
self.updated = utc_now()
|
|
268
273
|
|
|
269
274
|
def complete_step(self, index: int, agent: str | None = None) -> bool:
|
|
270
275
|
"""Mark a step as completed."""
|
|
271
276
|
if 0 <= index < len(self.steps):
|
|
272
277
|
self.steps[index].completed = True
|
|
273
278
|
self.steps[index].agent = agent
|
|
274
|
-
self.steps[index].timestamp =
|
|
275
|
-
self.updated =
|
|
279
|
+
self.steps[index].timestamp = utc_now()
|
|
280
|
+
self.updated = utc_now()
|
|
276
281
|
return True
|
|
277
282
|
return False
|
|
278
283
|
|
|
@@ -300,7 +305,7 @@ class Node(BaseModel):
|
|
|
300
305
|
self.context_tokens_used += tokens_used
|
|
301
306
|
self.context_peak_tokens = max(self.context_peak_tokens, peak_tokens)
|
|
302
307
|
self.context_cost_usd += cost_usd
|
|
303
|
-
self.updated =
|
|
308
|
+
self.updated = utc_now()
|
|
304
309
|
|
|
305
310
|
def context_stats(self) -> dict:
|
|
306
311
|
"""
|
|
@@ -831,9 +836,7 @@ class ContextSnapshot(BaseModel):
|
|
|
831
836
|
def from_dict(cls, data: dict) -> "ContextSnapshot":
|
|
832
837
|
"""Create from dictionary."""
|
|
833
838
|
return cls(
|
|
834
|
-
timestamp=datetime.fromisoformat(data["ts"])
|
|
835
|
-
if "ts" in data
|
|
836
|
-
else datetime.now(),
|
|
839
|
+
timestamp=datetime.fromisoformat(data["ts"]) if "ts" in data else utc_now(),
|
|
837
840
|
input_tokens=data.get("in", 0),
|
|
838
841
|
output_tokens=data.get("out", 0),
|
|
839
842
|
cache_creation_tokens=data.get("cache_create", 0),
|
|
@@ -847,6 +850,48 @@ class ContextSnapshot(BaseModel):
|
|
|
847
850
|
)
|
|
848
851
|
|
|
849
852
|
|
|
853
|
+
class ErrorEntry(BaseModel):
|
|
854
|
+
"""
|
|
855
|
+
An error record for session error tracking and debugging.
|
|
856
|
+
|
|
857
|
+
Stored inline within Session nodes for error analysis and debugging.
|
|
858
|
+
"""
|
|
859
|
+
|
|
860
|
+
timestamp: datetime = Field(default_factory=datetime.now)
|
|
861
|
+
error_type: str # Exception class name (ValueError, FileNotFoundError, etc.)
|
|
862
|
+
message: str # Error message
|
|
863
|
+
traceback: str | None = None # Full traceback for debugging
|
|
864
|
+
tool: str | None = None # Tool that caused the error (Edit, Bash, etc.)
|
|
865
|
+
context: str | None = None # Additional context information
|
|
866
|
+
session_id: str | None = None # Session ID for cross-referencing
|
|
867
|
+
locals_dump: str | None = None # JSON-serialized local variables at error point
|
|
868
|
+
stack_frames: list[dict[str, Any]] | None = (
|
|
869
|
+
None # Structured stack frame information
|
|
870
|
+
)
|
|
871
|
+
command_args: dict[str, Any] | None = None # Command arguments being executed
|
|
872
|
+
display_level: str = "minimal" # Display level: minimal, verbose, or debug
|
|
873
|
+
|
|
874
|
+
def to_html(self) -> str:
|
|
875
|
+
"""Convert error to HTML details element."""
|
|
876
|
+
attrs = [
|
|
877
|
+
f'data-ts="{self.timestamp.isoformat()}"',
|
|
878
|
+
f'data-error-type="{self.error_type}"',
|
|
879
|
+
]
|
|
880
|
+
if self.tool:
|
|
881
|
+
attrs.append(f'data-tool="{self.tool}"')
|
|
882
|
+
|
|
883
|
+
summary = f"<span class='error-type'>{self.error_type}</span>: {self.message}"
|
|
884
|
+
details = ""
|
|
885
|
+
if self.traceback:
|
|
886
|
+
details = f"<pre class='traceback'>{self.traceback}</pre>"
|
|
887
|
+
|
|
888
|
+
return f"<details class='error-item' {' '.join(attrs)}><summary>{summary}</summary>{details}</details>"
|
|
889
|
+
|
|
890
|
+
def to_context(self) -> str:
|
|
891
|
+
"""Lightweight context for AI agents."""
|
|
892
|
+
return f"[{self.timestamp.strftime('%H:%M:%S')}] ERROR {self.error_type}: {self.message}"
|
|
893
|
+
|
|
894
|
+
|
|
850
895
|
class ActivityEntry(BaseModel):
|
|
851
896
|
"""
|
|
852
897
|
A lightweight activity log entry for high-frequency events.
|
|
@@ -975,20 +1020,52 @@ class Session(BaseModel):
|
|
|
975
1020
|
}
|
|
976
1021
|
"""
|
|
977
1022
|
|
|
1023
|
+
# Error handling (Phase 1B)
|
|
1024
|
+
error_log: list[ErrorEntry] = Field(default_factory=list)
|
|
1025
|
+
"""Error records for this session with full tracebacks for debugging."""
|
|
1026
|
+
|
|
978
1027
|
def add_activity(self, entry: ActivityEntry) -> None:
|
|
979
1028
|
"""Add an activity entry to the log."""
|
|
980
1029
|
self.activity_log.append(entry)
|
|
981
1030
|
self.event_count += 1
|
|
982
|
-
self.last_activity =
|
|
1031
|
+
self.last_activity = utc_now()
|
|
983
1032
|
|
|
984
1033
|
# Track features worked on
|
|
985
1034
|
if entry.feature_id and entry.feature_id not in self.worked_on:
|
|
986
1035
|
self.worked_on.append(entry.feature_id)
|
|
987
1036
|
|
|
1037
|
+
def add_error(
|
|
1038
|
+
self,
|
|
1039
|
+
error_type: str,
|
|
1040
|
+
message: str,
|
|
1041
|
+
traceback: str | None = None,
|
|
1042
|
+
tool: str | None = None,
|
|
1043
|
+
context: str | None = None,
|
|
1044
|
+
) -> None:
|
|
1045
|
+
"""
|
|
1046
|
+
Add an error entry to the error log.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
error_type: Exception class name (ValueError, FileNotFoundError, etc.)
|
|
1050
|
+
message: Error message
|
|
1051
|
+
traceback: Full traceback for debugging
|
|
1052
|
+
tool: Tool that caused the error (Edit, Bash, etc.)
|
|
1053
|
+
context: Additional context information
|
|
1054
|
+
"""
|
|
1055
|
+
error = ErrorEntry(
|
|
1056
|
+
error_type=error_type,
|
|
1057
|
+
message=message,
|
|
1058
|
+
traceback=traceback,
|
|
1059
|
+
tool=tool,
|
|
1060
|
+
context=context,
|
|
1061
|
+
session_id=self.id,
|
|
1062
|
+
)
|
|
1063
|
+
self.error_log.append(error)
|
|
1064
|
+
|
|
988
1065
|
def end(self) -> None:
|
|
989
1066
|
"""Mark session as ended."""
|
|
990
1067
|
self.status = "ended"
|
|
991
|
-
self.ended_at =
|
|
1068
|
+
self.ended_at = utc_now()
|
|
992
1069
|
|
|
993
1070
|
def record_context(
|
|
994
1071
|
self, snapshot: ContextSnapshot, sample_interval: int = 10
|
|
@@ -1461,6 +1538,25 @@ class Session(BaseModel):
|
|
|
1461
1538
|
</table>
|
|
1462
1539
|
</section>"""
|
|
1463
1540
|
|
|
1541
|
+
# Build error log section
|
|
1542
|
+
error_html = ""
|
|
1543
|
+
if self.error_log:
|
|
1544
|
+
error_items = "\n ".join(
|
|
1545
|
+
error.to_html() for error in self.error_log
|
|
1546
|
+
)
|
|
1547
|
+
error_html = f"""
|
|
1548
|
+
<section data-error-log>
|
|
1549
|
+
<h3>Errors ({len(self.error_log)})</h3>
|
|
1550
|
+
<div class="error-log">
|
|
1551
|
+
{error_items}
|
|
1552
|
+
</div>
|
|
1553
|
+
<style>
|
|
1554
|
+
.error-item {{ margin: 10px 0; padding: 10px; border-left: 3px solid #ff6b6b; }}
|
|
1555
|
+
.error-type {{ font-weight: bold; color: #ff6b6b; }}
|
|
1556
|
+
.traceback {{ background: #f5f5f5; padding: 10px; overflow-x: auto; font-size: 0.9em; margin-top: 5px; }}
|
|
1557
|
+
</style>
|
|
1558
|
+
</section>"""
|
|
1559
|
+
|
|
1464
1560
|
title = self.title or f"Session {self.id}"
|
|
1465
1561
|
|
|
1466
1562
|
return f'''<!DOCTYPE html>
|
|
@@ -1489,7 +1585,7 @@ class Session(BaseModel):
|
|
|
1489
1585
|
<span class="badge">{self.event_count} events</span>
|
|
1490
1586
|
</div>
|
|
1491
1587
|
</header>
|
|
1492
|
-
{edges_html}{handoff_html}{context_html}{patterns_html}{activity_html}
|
|
1588
|
+
{edges_html}{handoff_html}{context_html}{error_html}{patterns_html}{activity_html}
|
|
1493
1589
|
</article>
|
|
1494
1590
|
</body>
|
|
1495
1591
|
</html>
|
|
@@ -2141,16 +2237,16 @@ class Todo(BaseModel):
|
|
|
2141
2237
|
def start(self) -> "Todo":
|
|
2142
2238
|
"""Mark todo as in progress."""
|
|
2143
2239
|
self.status = "in_progress"
|
|
2144
|
-
self.started_at =
|
|
2145
|
-
self.updated =
|
|
2240
|
+
self.started_at = utc_now()
|
|
2241
|
+
self.updated = utc_now()
|
|
2146
2242
|
return self
|
|
2147
2243
|
|
|
2148
2244
|
def complete(self, agent: str | None = None) -> "Todo":
|
|
2149
2245
|
"""Mark todo as completed."""
|
|
2150
2246
|
self.status = "completed"
|
|
2151
|
-
self.completed_at =
|
|
2247
|
+
self.completed_at = utc_now()
|
|
2152
2248
|
self.completed_by = agent
|
|
2153
|
-
self.updated =
|
|
2249
|
+
self.updated = utc_now()
|
|
2154
2250
|
|
|
2155
2251
|
# Calculate duration if started
|
|
2156
2252
|
if self.started_at:
|