htmlgraph 0.26.5__py3-none-any.whl → 0.26.7__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/.htmlgraph/.session-warning-state.json +1 -1
- htmlgraph/__init__.py +1 -1
- htmlgraph/api/main.py +50 -10
- htmlgraph/api/templates/dashboard-redesign.html +608 -54
- htmlgraph/api/templates/partials/activity-feed.html +21 -0
- htmlgraph/api/templates/partials/features.html +81 -12
- htmlgraph/api/templates/partials/orchestration.html +35 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +939 -0
- htmlgraph/cli/base.py +660 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +856 -0
- htmlgraph/cli/main.py +143 -0
- htmlgraph/cli/models.py +462 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +398 -0
- htmlgraph/cli/work/__init__.py +159 -0
- htmlgraph/cli/work/features.py +567 -0
- htmlgraph/cli/work/orchestration.py +675 -0
- htmlgraph/cli/work/sessions.py +465 -0
- htmlgraph/cli/work/tracks.py +485 -0
- htmlgraph/dashboard.html +6414 -634
- htmlgraph/db/schema.py +8 -3
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +20 -13
- htmlgraph/docs/README.md +2 -3
- htmlgraph/hooks/event_tracker.py +355 -26
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/orchestrator.py +137 -71
- htmlgraph/hooks/orchestrator_reflector.py +23 -0
- htmlgraph/hooks/pretooluse.py +29 -6
- htmlgraph/hooks/session_handler.py +28 -0
- htmlgraph/hooks/session_summary.py +391 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +71 -12
- htmlgraph/hooks/validator.py +192 -79
- htmlgraph/operations/__init__.py +18 -0
- htmlgraph/operations/initialization.py +596 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/orchestration/__init__.py +16 -1
- htmlgraph/orchestration/claude_launcher.py +185 -0
- htmlgraph/orchestration/command_builder.py +71 -0
- htmlgraph/orchestration/headless_spawner.py +72 -1332
- htmlgraph/orchestration/plugin_manager.py +136 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +170 -0
- htmlgraph/orchestration/spawners/codex.py +442 -0
- htmlgraph/orchestration/spawners/copilot.py +299 -0
- htmlgraph/orchestration/spawners/gemini.py +478 -0
- htmlgraph/orchestration/subprocess_runner.py +33 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +620 -55
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +45 -12
- htmlgraph/transcript.py +16 -4
- htmlgraph-0.26.7.data/data/htmlgraph/dashboard.html +6592 -0
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/METADATA +1 -1
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/RECORD +68 -34
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -7256
- htmlgraph-0.26.5.data/data/htmlgraph/dashboard.html +0 -812
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.26.5.data → htmlgraph-0.26.7.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.26.5.dist-info → htmlgraph-0.26.7.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session Summary Module - CIGS Integration
|
|
3
|
+
|
|
4
|
+
Generates comprehensive session summaries with CIGS analytics at session end.
|
|
5
|
+
This module is loaded by the Stop hook.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_project_dir(cwd: str | None = None) -> str:
|
|
17
|
+
"""Resolve project directory (git root or cwd)."""
|
|
18
|
+
env_dir = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
19
|
+
if env_dir:
|
|
20
|
+
return env_dir
|
|
21
|
+
start_dir = cwd or os.getcwd()
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
cwd=start_dir,
|
|
28
|
+
timeout=5,
|
|
29
|
+
)
|
|
30
|
+
if result.returncode == 0:
|
|
31
|
+
return result.stdout.strip()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
return start_dir
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _bootstrap_pythonpath(project_dir: str) -> None:
|
|
38
|
+
"""Add local src/python to PYTHONPATH for CIGS imports."""
|
|
39
|
+
repo_src = Path(project_dir) / "src" / "python"
|
|
40
|
+
if repo_src.exists():
|
|
41
|
+
sys.path.insert(0, str(repo_src))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Try to import CIGS modules
|
|
45
|
+
try:
|
|
46
|
+
project_dir_for_import = _resolve_project_dir()
|
|
47
|
+
_bootstrap_pythonpath(project_dir_for_import)
|
|
48
|
+
|
|
49
|
+
from htmlgraph.cigs.autonomy import AutonomyRecommender
|
|
50
|
+
from htmlgraph.cigs.cost import CostCalculator
|
|
51
|
+
from htmlgraph.cigs.patterns import PatternDetector
|
|
52
|
+
from htmlgraph.cigs.tracker import ViolationTracker
|
|
53
|
+
|
|
54
|
+
CIGS_AVAILABLE = True
|
|
55
|
+
except Exception:
|
|
56
|
+
CIGS_AVAILABLE = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CIGSSessionSummarizer:
|
|
60
|
+
"""
|
|
61
|
+
Generate comprehensive session summary with CIGS analytics.
|
|
62
|
+
|
|
63
|
+
Implements section 2.5 of CIGS design document:
|
|
64
|
+
- Load session violations from ViolationTracker
|
|
65
|
+
- Analyze session patterns using PatternDetector
|
|
66
|
+
- Calculate session costs
|
|
67
|
+
- Generate autonomy recommendation for next session
|
|
68
|
+
- Build comprehensive session summary
|
|
69
|
+
- Persist summary to .htmlgraph/cigs/session-summaries/{session_id}.json
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, graph_dir: Path):
|
|
73
|
+
"""Initialize session summarizer.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
graph_dir: Path to .htmlgraph directory
|
|
77
|
+
"""
|
|
78
|
+
if not CIGS_AVAILABLE:
|
|
79
|
+
raise RuntimeError("CIGS modules not available")
|
|
80
|
+
|
|
81
|
+
self.graph_dir = Path(graph_dir)
|
|
82
|
+
self.cigs_dir = self.graph_dir / "cigs"
|
|
83
|
+
self.summaries_dir = self.cigs_dir / "session-summaries"
|
|
84
|
+
|
|
85
|
+
# Ensure directories exist
|
|
86
|
+
self.summaries_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
# Initialize CIGS components
|
|
89
|
+
self.tracker = ViolationTracker(graph_dir)
|
|
90
|
+
self.pattern_detector = PatternDetector()
|
|
91
|
+
self.cost_calculator = CostCalculator()
|
|
92
|
+
self.autonomy_recommender = AutonomyRecommender()
|
|
93
|
+
|
|
94
|
+
def summarize(self, session_id: str | None = None) -> dict:
|
|
95
|
+
"""
|
|
96
|
+
Generate comprehensive session summary.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
session_id: Session ID (defaults to current/active session)
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Hook response with session summary
|
|
103
|
+
"""
|
|
104
|
+
# Get session violations
|
|
105
|
+
violations = self.tracker.get_session_violations(session_id)
|
|
106
|
+
|
|
107
|
+
# Detect patterns from recent violations
|
|
108
|
+
patterns = self._detect_patterns(violations.violations)
|
|
109
|
+
|
|
110
|
+
# Calculate cost metrics
|
|
111
|
+
costs = self._calculate_costs(violations)
|
|
112
|
+
|
|
113
|
+
# Generate autonomy recommendation for next session
|
|
114
|
+
autonomy_rec = self.autonomy_recommender.recommend(
|
|
115
|
+
violations=violations,
|
|
116
|
+
patterns=patterns,
|
|
117
|
+
compliance_history=self._get_compliance_history(),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Build summary text
|
|
121
|
+
summary_text = self._build_summary_text(
|
|
122
|
+
violations, patterns, costs, autonomy_rec
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Persist summary for future reference
|
|
126
|
+
self._persist_summary(
|
|
127
|
+
violations.session_id,
|
|
128
|
+
{
|
|
129
|
+
"session_id": violations.session_id,
|
|
130
|
+
"violations": violations.to_dict(),
|
|
131
|
+
"patterns": [p.to_dict() for p in patterns],
|
|
132
|
+
"costs": costs,
|
|
133
|
+
"autonomy_recommendation": autonomy_rec.to_dict(),
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"hookSpecificOutput": {
|
|
139
|
+
"hookEventName": "Stop",
|
|
140
|
+
"additionalContext": summary_text,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def _detect_patterns(self, violation_records: list) -> list:
|
|
145
|
+
"""
|
|
146
|
+
Detect behavioral patterns from violation records.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
violation_records: List of ViolationRecord instances
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of detected PatternRecord instances
|
|
153
|
+
"""
|
|
154
|
+
if not violation_records:
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
# Convert violations to tool history format
|
|
158
|
+
history: list[dict[str, Any]] = []
|
|
159
|
+
for v in violation_records:
|
|
160
|
+
history.append(
|
|
161
|
+
{
|
|
162
|
+
"tool": v.tool,
|
|
163
|
+
"command": v.tool_params.get("command", ""),
|
|
164
|
+
"file_path": v.tool_params.get("file_path", ""),
|
|
165
|
+
"prompt": v.tool_params.get("prompt", ""),
|
|
166
|
+
"timestamp": v.timestamp,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Detect all patterns
|
|
171
|
+
patterns = self.pattern_detector.detect_all_patterns(history)
|
|
172
|
+
return patterns # type: ignore[no-any-return]
|
|
173
|
+
|
|
174
|
+
def _calculate_costs(self, violations: Any) -> dict:
|
|
175
|
+
"""
|
|
176
|
+
Calculate cost metrics from violations.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
violations: SessionViolationSummary
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Dictionary with cost metrics
|
|
183
|
+
"""
|
|
184
|
+
total_tokens = sum(v.actual_cost_tokens for v in violations.violations)
|
|
185
|
+
optimal_tokens = sum(v.optimal_cost_tokens for v in violations.violations)
|
|
186
|
+
waste_tokens = violations.total_waste_tokens
|
|
187
|
+
|
|
188
|
+
if total_tokens > 0:
|
|
189
|
+
waste_percentage = (waste_tokens / total_tokens) * 100
|
|
190
|
+
efficiency_score = (optimal_tokens / total_tokens) * 100
|
|
191
|
+
else:
|
|
192
|
+
waste_percentage = 0.0
|
|
193
|
+
efficiency_score = 100.0
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"total_tokens": total_tokens,
|
|
197
|
+
"optimal_tokens": optimal_tokens,
|
|
198
|
+
"waste_tokens": waste_tokens,
|
|
199
|
+
"waste_percentage": waste_percentage,
|
|
200
|
+
"efficiency_score": efficiency_score,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
def _get_compliance_history(self) -> list[float]:
|
|
204
|
+
"""
|
|
205
|
+
Get compliance history from last 5 sessions.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of compliance rates (0.0-1.0)
|
|
209
|
+
"""
|
|
210
|
+
# Get recent violations (last 5 sessions)
|
|
211
|
+
recent = self.tracker.get_recent_violations(sessions=5)
|
|
212
|
+
|
|
213
|
+
# Group by session and calculate compliance rates
|
|
214
|
+
sessions: dict[str, list] = {}
|
|
215
|
+
for v in recent:
|
|
216
|
+
if v.session_id not in sessions:
|
|
217
|
+
sessions[v.session_id] = []
|
|
218
|
+
sessions[v.session_id].append(v)
|
|
219
|
+
|
|
220
|
+
# Calculate compliance rate per session
|
|
221
|
+
compliance_rates = []
|
|
222
|
+
for session_id, session_violations in sessions.items():
|
|
223
|
+
total_violations = len(session_violations)
|
|
224
|
+
# Compliance rate: 1.0 = no violations, decreases with more violations
|
|
225
|
+
compliance_rate = max(0.0, 1.0 - (total_violations / 5.0))
|
|
226
|
+
compliance_rates.append(compliance_rate)
|
|
227
|
+
|
|
228
|
+
return compliance_rates[-5:] if compliance_rates else [1.0]
|
|
229
|
+
|
|
230
|
+
def _build_summary_text(
|
|
231
|
+
self, violations: Any, patterns: Any, costs: Any, autonomy_rec: Any
|
|
232
|
+
) -> str:
|
|
233
|
+
"""
|
|
234
|
+
Build human-readable session summary.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
violations: SessionViolationSummary
|
|
238
|
+
patterns: List of PatternRecord instances
|
|
239
|
+
costs: Cost metrics dictionary
|
|
240
|
+
autonomy_rec: AutonomyLevel recommendation
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Formatted markdown summary
|
|
244
|
+
"""
|
|
245
|
+
# Compliance rate
|
|
246
|
+
compliance_pct = violations.compliance_rate * 100
|
|
247
|
+
|
|
248
|
+
# Circuit breaker status
|
|
249
|
+
breaker_status = (
|
|
250
|
+
"🚨 TRIGGERED" if violations.circuit_breaker_triggered else "✅ OK"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Format violations by type
|
|
254
|
+
violations_detail = ""
|
|
255
|
+
if violations.violations_by_type:
|
|
256
|
+
for vtype, count in violations.violations_by_type.items():
|
|
257
|
+
violations_detail += f" - {vtype}: {count}\n"
|
|
258
|
+
else:
|
|
259
|
+
violations_detail = " - No violations detected\n"
|
|
260
|
+
|
|
261
|
+
# Format detected patterns
|
|
262
|
+
patterns_text = self._format_patterns(patterns)
|
|
263
|
+
|
|
264
|
+
# Format anti-patterns
|
|
265
|
+
anti_patterns_text = self._format_anti_patterns(patterns)
|
|
266
|
+
|
|
267
|
+
# Build summary
|
|
268
|
+
summary = f"""## 📊 CIGS Session Summary
|
|
269
|
+
|
|
270
|
+
### Delegation Metrics
|
|
271
|
+
- **Compliance Rate:** {compliance_pct:.0f}%
|
|
272
|
+
- **Violations:** {violations.total_violations} (circuit breaker threshold: 3)
|
|
273
|
+
- **Circuit Breaker:** {breaker_status}
|
|
274
|
+
|
|
275
|
+
### Violation Breakdown
|
|
276
|
+
{violations_detail}
|
|
277
|
+
|
|
278
|
+
### Cost Analysis
|
|
279
|
+
- **Total Context Used:** {costs["total_tokens"]} tokens
|
|
280
|
+
- **Estimated Waste:** {costs["waste_tokens"]} tokens ({costs["waste_percentage"]:.1f}%)
|
|
281
|
+
- **Optimal Path Cost:** {costs["optimal_tokens"]} tokens
|
|
282
|
+
- **Efficiency Score:** {costs["efficiency_score"]:.0f}/100
|
|
283
|
+
|
|
284
|
+
{patterns_text}
|
|
285
|
+
|
|
286
|
+
{anti_patterns_text}
|
|
287
|
+
|
|
288
|
+
### Autonomy Recommendation
|
|
289
|
+
**Next Session Level:** {autonomy_rec.level.upper()}
|
|
290
|
+
**Messaging Intensity:** {autonomy_rec.messaging_intensity}
|
|
291
|
+
**Enforcement Mode:** {autonomy_rec.enforcement_mode}
|
|
292
|
+
|
|
293
|
+
**Reason:** {autonomy_rec.reason}
|
|
294
|
+
|
|
295
|
+
### Learning Applied
|
|
296
|
+
- ✅ Violation patterns added to detection model
|
|
297
|
+
- ✅ Cost predictions updated with actual session data
|
|
298
|
+
- ✅ Messaging intensity adjusted for next session: {autonomy_rec.messaging_intensity}
|
|
299
|
+
- ✅ Session summary persisted to `.htmlgraph/cigs/session-summaries/`
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
**Next Steps:**
|
|
304
|
+
1. Review detected anti-patterns (if any) and adjust workflow
|
|
305
|
+
2. Your autonomy level for next session: **{autonomy_rec.level.upper()}**
|
|
306
|
+
3. Guidance intensity: **{autonomy_rec.messaging_intensity}**
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
return summary
|
|
310
|
+
|
|
311
|
+
def _format_patterns(self, patterns: list) -> str:
|
|
312
|
+
"""Format detected good patterns."""
|
|
313
|
+
good_patterns = [p for p in patterns if p.pattern_type == "good-pattern"]
|
|
314
|
+
|
|
315
|
+
if not good_patterns:
|
|
316
|
+
return "### Detected Patterns\n- No significant patterns detected"
|
|
317
|
+
|
|
318
|
+
text = "### Detected Patterns\n"
|
|
319
|
+
for p in good_patterns:
|
|
320
|
+
text += f"- ✅ **{p.name}**: {p.description}\n"
|
|
321
|
+
text += f" - Occurrences: {p.occurrence_count}\n"
|
|
322
|
+
|
|
323
|
+
return text
|
|
324
|
+
|
|
325
|
+
def _format_anti_patterns(self, patterns: list) -> str:
|
|
326
|
+
"""Format detected anti-patterns with remediation."""
|
|
327
|
+
anti_patterns = [p for p in patterns if p.pattern_type == "anti-pattern"]
|
|
328
|
+
|
|
329
|
+
if not anti_patterns:
|
|
330
|
+
return "### Anti-Patterns Identified\n- ✅ No anti-patterns detected"
|
|
331
|
+
|
|
332
|
+
text = "### Anti-Patterns Identified\n"
|
|
333
|
+
for p in anti_patterns:
|
|
334
|
+
text += f"- ⚠️ **{p.name}**: {p.description}\n"
|
|
335
|
+
text += f" - Occurrences: {p.occurrence_count}\n"
|
|
336
|
+
if p.correct_approach:
|
|
337
|
+
text += f" - **Correct Approach:** {p.correct_approach}\n"
|
|
338
|
+
if p.delegation_suggestion:
|
|
339
|
+
text += f" - **Suggested Delegation:** {p.delegation_suggestion}\n"
|
|
340
|
+
|
|
341
|
+
return text
|
|
342
|
+
|
|
343
|
+
def _persist_summary(self, session_id: str, summary_data: dict) -> None:
|
|
344
|
+
"""
|
|
345
|
+
Persist session summary to file for future reference.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
session_id: Session identifier
|
|
349
|
+
summary_data: Summary dictionary to persist
|
|
350
|
+
"""
|
|
351
|
+
try:
|
|
352
|
+
summary_file = self.summaries_dir / f"{session_id}.json"
|
|
353
|
+
with open(summary_file, "w") as f:
|
|
354
|
+
json.dump(summary_data, f, indent=2, default=str)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
print(f"Warning: Failed to persist summary: {e}", file=sys.stderr)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def main() -> None:
|
|
360
|
+
"""Hook entry point for script wrapper."""
|
|
361
|
+
# Check if tracking is disabled
|
|
362
|
+
if os.environ.get("HTMLGRAPH_DISABLE_TRACKING") == "1":
|
|
363
|
+
print(json.dumps({"continue": True}))
|
|
364
|
+
sys.exit(0)
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
hook_input = json.load(sys.stdin)
|
|
368
|
+
except json.JSONDecodeError:
|
|
369
|
+
hook_input = {}
|
|
370
|
+
|
|
371
|
+
session_id = hook_input.get("session_id") or os.environ.get("CLAUDE_SESSION_ID")
|
|
372
|
+
cwd = hook_input.get("cwd")
|
|
373
|
+
project_dir = _resolve_project_dir(cwd if cwd else None)
|
|
374
|
+
graph_dir = Path(project_dir) / ".htmlgraph"
|
|
375
|
+
|
|
376
|
+
# Check if CIGS is enabled (disabled by default for now)
|
|
377
|
+
cigs_enabled = os.environ.get("HTMLGRAPH_CIGS_ENABLED") == "1"
|
|
378
|
+
|
|
379
|
+
if not cigs_enabled or not CIGS_AVAILABLE:
|
|
380
|
+
# CIGS not enabled or not available, just output empty response
|
|
381
|
+
print(json.dumps({"continue": True}))
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Generate CIGS session summary
|
|
385
|
+
try:
|
|
386
|
+
summarizer = CIGSSessionSummarizer(graph_dir)
|
|
387
|
+
result = summarizer.summarize(session_id)
|
|
388
|
+
print(json.dumps(result))
|
|
389
|
+
except Exception as e:
|
|
390
|
+
print(f"Warning: Could not generate CIGS summary: {e}", file=sys.stderr)
|
|
391
|
+
print(json.dumps({"continue": True}))
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Subagent Context Detection for Orchestrator Mode
|
|
3
|
+
|
|
4
|
+
This module provides utilities to detect when code is executing within a
|
|
5
|
+
delegated subagent context (spawned via Task() tool) vs. the main orchestrator.
|
|
6
|
+
|
|
7
|
+
Key Problem:
|
|
8
|
+
PreToolUse hooks (orchestrator-enforce.py, validator.py) enforce delegation
|
|
9
|
+
rules that block direct tool use in strict mode. However, subagents MUST use
|
|
10
|
+
tools directly - that's the delegated work. Without context detection, subagents
|
|
11
|
+
get blocked, making strict orchestrator mode unusable.
|
|
12
|
+
|
|
13
|
+
Solution:
|
|
14
|
+
Detect subagent context via multiple signals:
|
|
15
|
+
1. Environment variables set by Claude Code when spawning Task() subagents
|
|
16
|
+
2. Session state markers in database
|
|
17
|
+
3. Parent session tracking
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
from htmlgraph.hooks.subagent_detection import is_subagent_context
|
|
21
|
+
|
|
22
|
+
if is_subagent_context():
|
|
23
|
+
# Allow direct tool use - this is delegated work
|
|
24
|
+
return {"continue": True}
|
|
25
|
+
else:
|
|
26
|
+
# Enforce delegation rules - this is orchestrator
|
|
27
|
+
return enforce_delegation(tool, params)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_subagent_context() -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Check if we're executing within a delegated subagent (spawned via Task()).
|
|
38
|
+
|
|
39
|
+
Detection Strategy (in priority order):
|
|
40
|
+
1. CLAUDE_SUBAGENT_ID environment variable (set by Task() spawner)
|
|
41
|
+
2. CLAUDE_PARENT_SESSION_ID environment variable (set by Task() spawner)
|
|
42
|
+
3. Session state marker in database (is_subagent flag)
|
|
43
|
+
4. Active session has parent_session_id set
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if executing in subagent context, False if orchestrator context
|
|
47
|
+
|
|
48
|
+
Note:
|
|
49
|
+
- Gracefully degrades if detection mechanisms fail (returns False)
|
|
50
|
+
- False positives are safe (allow direct tool use)
|
|
51
|
+
- False negatives would break subagents (must be avoided)
|
|
52
|
+
"""
|
|
53
|
+
# Check 1: Direct environment variable from Task() spawner
|
|
54
|
+
if os.getenv("CLAUDE_SUBAGENT_ID"):
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
# Check 2: Parent session ID indicates we're a subagent
|
|
58
|
+
if os.getenv("CLAUDE_PARENT_SESSION_ID"):
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# Check 3: Session state marker in database
|
|
62
|
+
try:
|
|
63
|
+
session_state = _load_session_state()
|
|
64
|
+
if session_state.get("is_subagent", False):
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
# Check 4: Session has parent_session_id
|
|
68
|
+
if session_state.get("parent_session_id"):
|
|
69
|
+
return True
|
|
70
|
+
except Exception:
|
|
71
|
+
# Graceful degradation - if we can't check, assume NOT subagent
|
|
72
|
+
# This is safe because it only allows stricter enforcement
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# Check 5: Query database for active session with parent_session_id
|
|
76
|
+
try:
|
|
77
|
+
if _has_parent_session_in_db():
|
|
78
|
+
return True
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_session_state() -> dict[str, Any]:
|
|
86
|
+
"""
|
|
87
|
+
Load session state from .htmlgraph/session-state.json.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Session state dict, or empty dict if not found
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
# Find .htmlgraph directory
|
|
94
|
+
graph_dir = _find_graph_dir()
|
|
95
|
+
if not graph_dir:
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
state_file = graph_dir / "session-state.json"
|
|
99
|
+
if not state_file.exists():
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
import json
|
|
103
|
+
|
|
104
|
+
result: dict[str, Any] = json.loads(state_file.read_text())
|
|
105
|
+
return result
|
|
106
|
+
except Exception:
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _has_parent_session_in_db() -> bool:
|
|
111
|
+
"""
|
|
112
|
+
Check if current session has a parent_session_id in database.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if session is a subagent (has parent), False otherwise
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
graph_dir = _find_graph_dir()
|
|
119
|
+
if not graph_dir:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
db_path = graph_dir / "htmlgraph.db"
|
|
123
|
+
if not db_path.exists():
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
import sqlite3
|
|
127
|
+
|
|
128
|
+
# Get current session ID from environment or database
|
|
129
|
+
|
|
130
|
+
# We need hook_input to create context, but we don't have it here
|
|
131
|
+
# Fall back to environment check
|
|
132
|
+
session_id = os.getenv("HTMLGRAPH_SESSION_ID") or os.getenv("CLAUDE_SESSION_ID")
|
|
133
|
+
|
|
134
|
+
if not session_id:
|
|
135
|
+
# Try to get most recent session from database
|
|
136
|
+
conn = sqlite3.connect(str(db_path), timeout=1.0)
|
|
137
|
+
cursor = conn.cursor()
|
|
138
|
+
cursor.execute("""
|
|
139
|
+
SELECT session_id FROM sessions
|
|
140
|
+
WHERE status = 'active'
|
|
141
|
+
ORDER BY created_at DESC
|
|
142
|
+
LIMIT 1
|
|
143
|
+
""")
|
|
144
|
+
row = cursor.fetchone()
|
|
145
|
+
if row:
|
|
146
|
+
session_id = row[0]
|
|
147
|
+
conn.close()
|
|
148
|
+
|
|
149
|
+
if not session_id:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# Check if this session has a parent
|
|
153
|
+
conn = sqlite3.connect(str(db_path), timeout=1.0)
|
|
154
|
+
cursor = conn.cursor()
|
|
155
|
+
cursor.execute(
|
|
156
|
+
"""
|
|
157
|
+
SELECT parent_session_id FROM sessions
|
|
158
|
+
WHERE session_id = ?
|
|
159
|
+
""",
|
|
160
|
+
(session_id,),
|
|
161
|
+
)
|
|
162
|
+
row = cursor.fetchone()
|
|
163
|
+
conn.close()
|
|
164
|
+
|
|
165
|
+
if row and row[0]:
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _find_graph_dir() -> Path | None:
|
|
175
|
+
"""
|
|
176
|
+
Find .htmlgraph directory starting from current working directory.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Path to .htmlgraph directory, or None if not found
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
cwd = Path.cwd()
|
|
183
|
+
graph_dir = cwd / ".htmlgraph"
|
|
184
|
+
|
|
185
|
+
if graph_dir.exists():
|
|
186
|
+
return graph_dir
|
|
187
|
+
|
|
188
|
+
# Search up to 3 parent directories
|
|
189
|
+
for parent in [cwd.parent, cwd.parent.parent, cwd.parent.parent.parent]:
|
|
190
|
+
candidate = parent / ".htmlgraph"
|
|
191
|
+
if candidate.exists():
|
|
192
|
+
return candidate
|
|
193
|
+
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
__all__ = [
|
|
201
|
+
"is_subagent_context",
|
|
202
|
+
]
|
htmlgraph/hooks/subagent_stop.py
CHANGED
|
@@ -35,12 +35,61 @@ def get_parent_event_id() -> str | None:
|
|
|
35
35
|
|
|
36
36
|
Set by PreToolUse hook when Task() is detected.
|
|
37
37
|
|
|
38
|
+
NOTE: This relies on environment variables which DON'T persist between
|
|
39
|
+
hook invocations (each hook is a new subprocess). This function is kept
|
|
40
|
+
for backward compatibility but will almost always return None.
|
|
41
|
+
Use get_parent_event_id_from_database() instead.
|
|
42
|
+
|
|
38
43
|
Returns:
|
|
39
44
|
Parent event ID (evt-XXXXX) or None if not found
|
|
40
45
|
"""
|
|
41
46
|
return os.environ.get("HTMLGRAPH_PARENT_EVENT")
|
|
42
47
|
|
|
43
48
|
|
|
49
|
+
def get_parent_event_id_from_database(db_path: str) -> tuple[str | None, str | None]:
|
|
50
|
+
"""
|
|
51
|
+
Get the parent event ID by querying the database for active task_delegation events.
|
|
52
|
+
|
|
53
|
+
This is the reliable method for finding parent events since environment variables
|
|
54
|
+
don't persist between hook invocations.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
db_path: Path to SQLite database
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (parent_event_id, parent_start_time) or (None, None) if not found
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
conn = sqlite3.connect(db_path)
|
|
64
|
+
cursor = conn.cursor()
|
|
65
|
+
|
|
66
|
+
# Find the most recent task_delegation event that's still 'started'
|
|
67
|
+
cursor.execute(
|
|
68
|
+
"""
|
|
69
|
+
SELECT event_id, timestamp
|
|
70
|
+
FROM agent_events
|
|
71
|
+
WHERE event_type = 'task_delegation'
|
|
72
|
+
AND status = 'started'
|
|
73
|
+
AND timestamp >= datetime('now', '-10 minutes')
|
|
74
|
+
ORDER BY timestamp DESC
|
|
75
|
+
LIMIT 1
|
|
76
|
+
"""
|
|
77
|
+
)
|
|
78
|
+
row = cursor.fetchone()
|
|
79
|
+
conn.close()
|
|
80
|
+
|
|
81
|
+
if row:
|
|
82
|
+
logger.debug(f"Found active task_delegation from database: {row[0]}")
|
|
83
|
+
return row[0], row[1]
|
|
84
|
+
|
|
85
|
+
logger.debug("No active task_delegation found in database")
|
|
86
|
+
return None, None
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.warning(f"Error querying database for parent event: {e}")
|
|
90
|
+
return None, None
|
|
91
|
+
|
|
92
|
+
|
|
44
93
|
def get_session_id() -> str | None:
|
|
45
94
|
"""
|
|
46
95
|
Get the current session ID from environment.
|
|
@@ -222,14 +271,7 @@ def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
|
222
271
|
Returns:
|
|
223
272
|
Response: {"continue": True} with optional context
|
|
224
273
|
"""
|
|
225
|
-
# Get
|
|
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
|
|
274
|
+
# Get project directory and database path first (needed for database-based detection)
|
|
233
275
|
try:
|
|
234
276
|
from htmlgraph.config import get_database_path
|
|
235
277
|
|
|
@@ -244,12 +286,29 @@ def handle_subagent_stop(hook_input: dict[str, Any]) -> dict[str, Any]:
|
|
|
244
286
|
logger.warning(f"Error resolving database path: {e}")
|
|
245
287
|
return {"continue": True}
|
|
246
288
|
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
289
|
+
# Try environment variable first (unlikely to work, but kept for compatibility)
|
|
290
|
+
parent_event_id = get_parent_event_id()
|
|
291
|
+
parent_start_time = None
|
|
292
|
+
|
|
293
|
+
# Fall back to database-based detection (the reliable method)
|
|
294
|
+
if not parent_event_id:
|
|
295
|
+
parent_event_id, parent_start_time = get_parent_event_id_from_database(db_path)
|
|
296
|
+
|
|
297
|
+
if not parent_event_id:
|
|
298
|
+
logger.debug(
|
|
299
|
+
"No parent event ID found (env or database), skipping subagent stop tracking"
|
|
300
|
+
)
|
|
251
301
|
return {"continue": True}
|
|
252
302
|
|
|
303
|
+
logger.info(f"SubagentStop: Found parent event {parent_event_id}")
|
|
304
|
+
|
|
305
|
+
# Get parent event start time if not already retrieved from database
|
|
306
|
+
if not parent_start_time:
|
|
307
|
+
parent_start_time = get_parent_event_start_time(db_path, parent_event_id)
|
|
308
|
+
if not parent_start_time:
|
|
309
|
+
logger.warning(f"Could not find parent event start time: {parent_event_id}")
|
|
310
|
+
return {"continue": True}
|
|
311
|
+
|
|
253
312
|
# Count child spikes
|
|
254
313
|
child_spike_count = count_child_spikes(db_path, parent_event_id, parent_start_time)
|
|
255
314
|
|