htmlgraph 0.23.5__py3-none-any.whl → 0.24.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.
Files changed (29) hide show
  1. htmlgraph/__init__.py +5 -1
  2. htmlgraph/cigs/__init__.py +77 -0
  3. htmlgraph/cigs/autonomy.py +385 -0
  4. htmlgraph/cigs/cost.py +475 -0
  5. htmlgraph/cigs/messages_basic.py +472 -0
  6. htmlgraph/cigs/messaging.py +365 -0
  7. htmlgraph/cigs/models.py +771 -0
  8. htmlgraph/cigs/pattern_storage.py +427 -0
  9. htmlgraph/cigs/patterns.py +503 -0
  10. htmlgraph/cigs/posttool_analyzer.py +234 -0
  11. htmlgraph/cigs/tracker.py +317 -0
  12. htmlgraph/cli.py +413 -11
  13. htmlgraph/hooks/cigs_pretool_enforcer.py +350 -0
  14. htmlgraph/hooks/posttooluse.py +50 -2
  15. htmlgraph/hooks/task_enforcer.py +60 -4
  16. htmlgraph/models.py +14 -1
  17. htmlgraph/orchestration/headless_spawner.py +519 -21
  18. htmlgraph/orchestrator-system-prompt-optimized.txt +259 -53
  19. htmlgraph/reflection.py +442 -0
  20. htmlgraph/sdk.py +26 -9
  21. {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.1.dist-info}/METADATA +2 -1
  22. {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.1.dist-info}/RECORD +29 -17
  23. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/dashboard.html +0 -0
  24. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/styles.css +0 -0
  25. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  26. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  27. {htmlgraph-0.23.5.data → htmlgraph-0.24.1.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  28. {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.1.dist-info}/WHEEL +0 -0
  29. {htmlgraph-0.23.5.dist-info → htmlgraph-0.24.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,234 @@
1
+ """
2
+ CIGSPostToolAnalyzer for PostToolUse Hook Integration
3
+
4
+ Provides cost accounting and reinforcement messages for PostToolUse hook.
5
+ Integrates with ViolationTracker and CostCalculator to analyze actual costs
6
+ and generate appropriate feedback (positive reinforcement or cost accounting).
7
+
8
+ Design Reference:
9
+ - CIGS Design Doc: .htmlgraph/spikes/computational-imperative-guidance-system-design.md
10
+ - Part 2.4: PostToolUse Hook Enhancement
11
+ """
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from htmlgraph.cigs.cost import CostCalculator
17
+ from htmlgraph.cigs.messaging import PositiveReinforcementGenerator
18
+ from htmlgraph.cigs.models import TokenCost
19
+ from htmlgraph.cigs.tracker import ViolationTracker
20
+
21
+
22
+ class CIGSPostToolAnalyzer:
23
+ """
24
+ Analyze tool execution results and provide cost-aware feedback.
25
+
26
+ Integrates with PostToolUse hook to:
27
+ 1. Calculate actual cost using CostCalculator
28
+ 2. Load prediction from ViolationTracker (if violation was recorded)
29
+ 3. Determine if operation was delegation (Task, spawn_*)
30
+ 4. Generate positive reinforcement for delegations
31
+ 5. Generate cost accounting for direct executions
32
+ 6. Update violation tracker with actual costs
33
+ """
34
+
35
+ def __init__(self, graph_dir: Path | None = None):
36
+ """
37
+ Initialize CIGSPostToolAnalyzer.
38
+
39
+ Args:
40
+ graph_dir: Root directory for HtmlGraph (defaults to .htmlgraph)
41
+ """
42
+ if graph_dir is None:
43
+ graph_dir = Path.cwd() / ".htmlgraph"
44
+
45
+ self.graph_dir = Path(graph_dir)
46
+ self.cost_calculator = CostCalculator()
47
+ self.tracker = ViolationTracker(self.graph_dir)
48
+ self.positive_gen = PositiveReinforcementGenerator()
49
+
50
+ def analyze(self, tool: str, params: dict, result: dict) -> dict[str, Any]:
51
+ """
52
+ Analyze tool execution and provide feedback.
53
+
54
+ Args:
55
+ tool: Tool that was executed (Read, Task, Edit, etc.)
56
+ params: Tool parameters
57
+ result: Tool execution result
58
+
59
+ Returns:
60
+ Hook response dict with feedback:
61
+ {
62
+ "hookSpecificOutput": {
63
+ "hookEventName": "PostToolUse",
64
+ "additionalContext": "Feedback message"
65
+ }
66
+ }
67
+ """
68
+ # Calculate actual cost
69
+ actual_cost = self.cost_calculator.calculate_actual_cost(tool, result)
70
+
71
+ # Determine if this was a delegation or direct execution
72
+ was_delegation = self._is_delegation(tool)
73
+
74
+ if was_delegation:
75
+ return self._positive_reinforcement(tool, actual_cost)
76
+ else:
77
+ return self._cost_accounting(tool, actual_cost)
78
+
79
+ def _is_delegation(self, tool: str) -> bool:
80
+ """
81
+ Check if tool represents delegation.
82
+
83
+ Args:
84
+ tool: Tool name
85
+
86
+ Returns:
87
+ True if tool is a delegation operation
88
+ """
89
+ return tool == "Task" or tool.startswith("spawn_")
90
+
91
+ def _positive_reinforcement(self, tool: str, cost: TokenCost) -> dict[str, Any]:
92
+ """
93
+ Provide positive reinforcement for correct delegation.
94
+
95
+ Args:
96
+ tool: Delegation tool used (Task, spawn_gemini, etc.)
97
+ cost: Actual token cost breakdown
98
+
99
+ Returns:
100
+ Hook response with positive reinforcement message
101
+ """
102
+ # Get current session stats
103
+ violations = self.tracker.get_session_violations()
104
+ compliance_rate = self._calculate_compliance_rate(violations.total_violations)
105
+ session_waste = violations.total_waste_tokens
106
+
107
+ # Generate positive message
108
+ message = self.positive_gen.generate(
109
+ tool=tool,
110
+ cost_savings=cost.estimated_savings,
111
+ compliance_rate=compliance_rate,
112
+ session_waste=session_waste,
113
+ )
114
+
115
+ return {
116
+ "hookSpecificOutput": {
117
+ "hookEventName": "PostToolUse",
118
+ "additionalContext": message,
119
+ }
120
+ }
121
+
122
+ def _cost_accounting(self, tool: str, cost: TokenCost) -> dict[str, Any]:
123
+ """
124
+ Account for cost of direct execution.
125
+
126
+ Args:
127
+ tool: Tool that was executed directly
128
+ cost: Actual token cost breakdown
129
+
130
+ Returns:
131
+ Hook response with cost accounting message
132
+ """
133
+ # Update violation tracker with actual cost
134
+ self.tracker.record_actual_cost(tool, cost)
135
+
136
+ # Get session violations for context
137
+ violations = self.tracker.get_session_violations()
138
+
139
+ # Check if this was a warned violation (look for recent violation with this tool)
140
+ has_violation = any(
141
+ v.tool == tool for v in violations.violations[-3:] if violations.violations
142
+ )
143
+
144
+ if has_violation or violations.total_violations > 0:
145
+ # This was likely a warned violation - provide cost impact
146
+ message = self._generate_violation_cost_message(
147
+ tool, cost, violations.total_violations, violations.total_waste_tokens
148
+ )
149
+ else:
150
+ # Allowed operation - just informational
151
+ message = f"Operation completed. Cost: {cost.total_tokens} tokens."
152
+
153
+ return {
154
+ "hookSpecificOutput": {
155
+ "hookEventName": "PostToolUse",
156
+ "additionalContext": message,
157
+ }
158
+ }
159
+
160
+ def _generate_violation_cost_message(
161
+ self, tool: str, cost: TokenCost, violation_count: int, total_waste: int
162
+ ) -> str:
163
+ """
164
+ Generate cost impact message for violation.
165
+
166
+ Args:
167
+ tool: Tool that was executed
168
+ cost: Actual token cost
169
+ violation_count: Total violations in session
170
+ total_waste: Total wasted tokens in session
171
+
172
+ Returns:
173
+ Formatted cost impact message
174
+ """
175
+ # Calculate optimal cost for this tool
176
+ optimal_cost = cost.subagent_tokens + cost.orchestrator_tokens
177
+ waste = cost.total_tokens - optimal_cost
178
+
179
+ # Calculate compliance rate
180
+ compliance_rate = self._calculate_compliance_rate(violation_count)
181
+
182
+ message_parts = [
183
+ "📊 Direct execution completed.",
184
+ "",
185
+ "**Cost Impact (Violation):**",
186
+ f"- Actual cost: {cost.total_tokens:,} tokens",
187
+ f"- If delegated: ~{optimal_cost:,} tokens",
188
+ f"- Waste: {waste:,} tokens ({(waste / cost.total_tokens * 100):.0f}% overhead)",
189
+ "",
190
+ "**Session Statistics:**",
191
+ f"- Violations: {violation_count}",
192
+ f"- Total waste: {total_waste:,} tokens",
193
+ f"- Delegation compliance: {compliance_rate:.0%}",
194
+ "",
195
+ "REFLECTION: Was this delegation worth the context cost?",
196
+ f"The same operation via Task() would have cost ~{optimal_cost:,} tokens",
197
+ "with full isolation of tactical details.",
198
+ ]
199
+
200
+ return "\n".join(message_parts)
201
+
202
+ def _calculate_compliance_rate(self, violation_count: int) -> float:
203
+ """
204
+ Calculate delegation compliance rate.
205
+
206
+ Args:
207
+ violation_count: Number of violations
208
+
209
+ Returns:
210
+ Compliance rate from 0.0 to 1.0
211
+ """
212
+ # For simplicity: (max_violations - actual) / max_violations
213
+ # where max_violations = 5 (violation rate saturates)
214
+ max_violations = 5
215
+ return max(0.0, 1.0 - (violation_count / max_violations))
216
+
217
+ def get_session_summary(self) -> dict[str, Any]:
218
+ """
219
+ Get session summary for Stop hook.
220
+
221
+ Returns:
222
+ Summary dict with metrics
223
+ """
224
+ violations = self.tracker.get_session_violations()
225
+
226
+ return {
227
+ "total_violations": violations.total_violations,
228
+ "violations_by_type": {
229
+ str(k): v for k, v in violations.violations_by_type.items()
230
+ },
231
+ "total_waste_tokens": violations.total_waste_tokens,
232
+ "circuit_breaker_triggered": violations.circuit_breaker_triggered,
233
+ "compliance_rate": violations.compliance_rate,
234
+ }
@@ -0,0 +1,317 @@
1
+ """
2
+ ViolationTracker for CIGS (Computational Imperative Guidance System)
3
+
4
+ Tracks delegation violations in JSONL format, providing thread-safe access
5
+ to violation records and session metrics.
6
+
7
+ Reference: .htmlgraph/spikes/computational-imperative-guidance-system-design.md (Part 3)
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import threading
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from uuid import uuid4
16
+
17
+ from htmlgraph.cigs.models import (
18
+ OperationClassification,
19
+ SessionViolationSummary,
20
+ TokenCost,
21
+ ViolationRecord,
22
+ ViolationType,
23
+ )
24
+
25
+
26
+ class ViolationTracker:
27
+ """
28
+ Thread-safe tracker for delegation violations.
29
+
30
+ Stores violations in JSONL format at `.htmlgraph/cigs/violations.jsonl`
31
+ """
32
+
33
+ def __init__(self, graph_dir: Path | None = None):
34
+ """
35
+ Initialize ViolationTracker.
36
+
37
+ Args:
38
+ graph_dir: Root directory for HtmlGraph (defaults to .htmlgraph)
39
+ """
40
+ if graph_dir is None:
41
+ graph_dir = Path.cwd() / ".htmlgraph"
42
+
43
+ self.graph_dir = Path(graph_dir)
44
+ self.cigs_dir = self.graph_dir / "cigs"
45
+ self.violations_file = self.cigs_dir / "violations.jsonl"
46
+
47
+ # Create directory if needed
48
+ self.cigs_dir.mkdir(parents=True, exist_ok=True)
49
+
50
+ # Thread-safe access
51
+ self._lock = threading.RLock()
52
+
53
+ # Current session ID (set externally or detect from environment)
54
+ self._session_id: str | None = self._detect_session_id()
55
+
56
+ def _detect_session_id(self) -> str | None:
57
+ """Detect current session ID from environment or HtmlGraph session manager."""
58
+ # First check environment variable
59
+ if "HTMLGRAPH_SESSION_ID" in os.environ:
60
+ return os.environ["HTMLGRAPH_SESSION_ID"]
61
+
62
+ # Try to get from session manager if available
63
+ try:
64
+ from htmlgraph.session_manager import SessionManager
65
+
66
+ sm = SessionManager(self.graph_dir)
67
+ current = sm.get_active_session()
68
+ if current:
69
+ return str(current.id)
70
+ except Exception:
71
+ pass
72
+
73
+ return None
74
+
75
+ def record_violation(
76
+ self,
77
+ tool: str,
78
+ params: dict,
79
+ classification: OperationClassification,
80
+ predicted_waste: int,
81
+ ) -> str:
82
+ """
83
+ Record a violation.
84
+
85
+ Args:
86
+ tool: Tool name (Read, Grep, Edit, etc.)
87
+ params: Tool parameters passed
88
+ classification: OperationClassification with context
89
+ predicted_waste: Predicted wasted tokens
90
+
91
+ Returns:
92
+ Violation ID
93
+ """
94
+ with self._lock:
95
+ violation_id = f"viol-{uuid4().hex[:12]}"
96
+ timestamp = datetime.utcnow()
97
+
98
+ # Get current session
99
+ session_id = self._session_id or "unknown"
100
+
101
+ record = ViolationRecord(
102
+ id=violation_id,
103
+ session_id=session_id,
104
+ timestamp=timestamp,
105
+ tool=tool,
106
+ tool_params=params,
107
+ violation_type=self._classify_violation(tool, classification),
108
+ context_before=None,
109
+ should_have_delegated_to=classification.suggested_delegation,
110
+ actual_cost_tokens=classification.predicted_cost,
111
+ optimal_cost_tokens=classification.optimal_cost,
112
+ waste_tokens=predicted_waste,
113
+ warning_level=1,
114
+ was_warned=False,
115
+ warning_ignored=False,
116
+ agent="claude-code",
117
+ feature_id=None,
118
+ )
119
+
120
+ # Append to JSONL file
121
+ self._append_violation(record)
122
+
123
+ return violation_id
124
+
125
+ def _classify_violation(
126
+ self, tool: str, classification: OperationClassification
127
+ ) -> ViolationType:
128
+ """Classify the violation type based on tool and context."""
129
+ if classification.is_exploration_sequence:
130
+ return ViolationType.EXPLORATION_SEQUENCE
131
+
132
+ # Map tools to violation types
133
+ if tool in ("Read", "Grep", "Glob"):
134
+ return ViolationType.DIRECT_EXPLORATION
135
+ elif tool in ("Edit", "Write", "NotebookEdit"):
136
+ return ViolationType.DIRECT_IMPLEMENTATION
137
+ elif tool == "Bash" and "git" in str(classification.reason).lower():
138
+ return ViolationType.DIRECT_GIT
139
+ elif tool == "Bash":
140
+ return ViolationType.DIRECT_TESTING
141
+
142
+ return ViolationType.DIRECT_EXPLORATION
143
+
144
+ def _append_violation(self, record: ViolationRecord) -> None:
145
+ """Append violation record to JSONL file (thread-safe)."""
146
+ with self._lock:
147
+ try:
148
+ with open(self.violations_file, "a") as f:
149
+ f.write(json.dumps(record.to_dict()) + "\n")
150
+ except Exception as e:
151
+ # Log but don't crash on storage errors
152
+ print(f"Warning: Failed to record violation: {e}")
153
+
154
+ def get_session_violations(
155
+ self, session_id: str | None = None
156
+ ) -> SessionViolationSummary:
157
+ """
158
+ Get violations for current or specific session.
159
+
160
+ Args:
161
+ session_id: Session ID (defaults to current session)
162
+
163
+ Returns:
164
+ SessionViolationSummary with aggregated metrics
165
+ """
166
+ if session_id is None:
167
+ session_id = self._session_id or "unknown"
168
+
169
+ with self._lock:
170
+ violations = self._load_violations()
171
+
172
+ # Filter to session
173
+ session_violations = [v for v in violations if v.session_id == session_id]
174
+
175
+ # Aggregate by type
176
+ violations_by_type = {}
177
+ for vtype in ViolationType:
178
+ count = sum(1 for v in session_violations if v.violation_type == vtype)
179
+ if count > 0:
180
+ violations_by_type[vtype] = count
181
+
182
+ # Calculate totals
183
+ total_violations = len(session_violations)
184
+ total_waste = sum(v.waste_tokens for v in session_violations)
185
+ circuit_breaker = total_violations >= 3
186
+
187
+ # Compliance rate: 1.0 = no violations, 0.0 = many violations
188
+ # For simplicity: (max_violations - actual) / max_violations
189
+ # where max_violations = 5 (violation rate saturates)
190
+ compliance_rate = max(0.0, 1.0 - (total_violations / 5.0))
191
+
192
+ return SessionViolationSummary(
193
+ session_id=session_id,
194
+ total_violations=total_violations,
195
+ violations_by_type=violations_by_type,
196
+ total_waste_tokens=total_waste,
197
+ circuit_breaker_triggered=circuit_breaker,
198
+ compliance_rate=compliance_rate,
199
+ violations=session_violations,
200
+ )
201
+
202
+ def get_recent_violations(self, sessions: int = 5) -> list[ViolationRecord]:
203
+ """
204
+ Get violations from last N sessions.
205
+
206
+ Args:
207
+ sessions: Number of sessions to include
208
+
209
+ Returns:
210
+ List of violation records from recent sessions
211
+ """
212
+ with self._lock:
213
+ violations = self._load_violations()
214
+
215
+ # Group by session and get N most recent
216
+ session_ids = set(v.session_id for v in violations)
217
+ recent_sessions = sorted(session_ids)[-sessions:]
218
+
219
+ return [v for v in violations if v.session_id in recent_sessions]
220
+
221
+ def record_actual_cost(self, tool: str, cost: TokenCost) -> None:
222
+ """
223
+ Update violation with actual cost after execution.
224
+
225
+ This updates the most recent violation for the given tool.
226
+
227
+ Args:
228
+ tool: Tool name
229
+ cost: TokenCost with actual metrics
230
+ """
231
+ with self._lock:
232
+ violations = self._load_violations()
233
+
234
+ if not violations:
235
+ return
236
+
237
+ # Find most recent violation for this tool (in current session)
238
+ session_id = self._session_id or "unknown"
239
+ matching = [
240
+ v for v in violations if v.tool == tool and v.session_id == session_id
241
+ ]
242
+
243
+ if not matching:
244
+ return
245
+
246
+ # Update most recent
247
+ latest = matching[-1]
248
+ latest.actual_cost_tokens = cost.total_tokens
249
+ latest.waste_tokens = cost.total_tokens - latest.optimal_cost_tokens
250
+
251
+ # Rewrite file (simple approach - replace entire file)
252
+ self._write_violations(violations)
253
+
254
+ def _write_violations(self, violations: list[ViolationRecord]) -> None:
255
+ """Write violations back to JSONL file."""
256
+ with self._lock:
257
+ try:
258
+ with open(self.violations_file, "w") as f:
259
+ for v in violations:
260
+ f.write(json.dumps(v.to_dict()) + "\n")
261
+ except Exception as e:
262
+ print(f"Warning: Failed to write violations: {e}")
263
+
264
+ def _load_violations(self) -> list[ViolationRecord]:
265
+ """Load all violations from JSONL file."""
266
+ violations: list[ViolationRecord] = []
267
+
268
+ if not self.violations_file.exists():
269
+ return violations
270
+
271
+ try:
272
+ with open(self.violations_file) as f:
273
+ for line in f:
274
+ if not line.strip():
275
+ continue
276
+ try:
277
+ data = json.loads(line)
278
+ violations.append(ViolationRecord.from_dict(data))
279
+ except (json.JSONDecodeError, ValueError) as e:
280
+ print(f"Warning: Failed to parse violation record: {e}")
281
+ continue
282
+ except Exception as e:
283
+ print(f"Warning: Failed to load violations: {e}")
284
+
285
+ return violations
286
+
287
+ def get_session_waste(self) -> int:
288
+ """
289
+ Get total wasted tokens for current session.
290
+
291
+ Returns:
292
+ Total waste tokens
293
+ """
294
+ summary = self.get_session_violations()
295
+ return summary.total_waste_tokens
296
+
297
+ def set_session_id(self, session_id: str) -> None:
298
+ """
299
+ Set the current session ID.
300
+
301
+ Args:
302
+ session_id: Session ID to use for subsequent violations
303
+ """
304
+ self._session_id = session_id
305
+
306
+ def clear_session_file(self) -> None:
307
+ """
308
+ Clear the violations file (useful for testing).
309
+
310
+ WARNING: This deletes all violation records!
311
+ """
312
+ with self._lock:
313
+ try:
314
+ if self.violations_file.exists():
315
+ self.violations_file.unlink()
316
+ except Exception as e:
317
+ print(f"Warning: Failed to clear violations file: {e}")