htmlgraph 0.24.2__py3-none-any.whl → 0.25.0__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 (103) hide show
  1. htmlgraph/__init__.py +20 -1
  2. htmlgraph/agent_detection.py +26 -10
  3. htmlgraph/analytics/cross_session.py +4 -3
  4. htmlgraph/analytics/work_type.py +52 -16
  5. htmlgraph/analytics_index.py +51 -19
  6. htmlgraph/api/__init__.py +3 -0
  7. htmlgraph/api/main.py +2115 -0
  8. htmlgraph/api/static/htmx.min.js +1 -0
  9. htmlgraph/api/static/style-redesign.css +1344 -0
  10. htmlgraph/api/static/style.css +1079 -0
  11. htmlgraph/api/templates/dashboard-redesign.html +812 -0
  12. htmlgraph/api/templates/dashboard.html +783 -0
  13. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  14. htmlgraph/api/templates/partials/activity-feed.html +570 -0
  15. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  16. htmlgraph/api/templates/partials/agents.html +317 -0
  17. htmlgraph/api/templates/partials/event-traces.html +373 -0
  18. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  19. htmlgraph/api/templates/partials/features.html +509 -0
  20. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  21. htmlgraph/api/templates/partials/metrics.html +346 -0
  22. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  23. htmlgraph/api/templates/partials/orchestration.html +163 -0
  24. htmlgraph/api/templates/partials/spawners.html +375 -0
  25. htmlgraph/atomic_ops.py +560 -0
  26. htmlgraph/builders/base.py +55 -1
  27. htmlgraph/builders/bug.py +17 -2
  28. htmlgraph/builders/chore.py +17 -2
  29. htmlgraph/builders/epic.py +17 -2
  30. htmlgraph/builders/feature.py +25 -2
  31. htmlgraph/builders/phase.py +17 -2
  32. htmlgraph/builders/spike.py +27 -2
  33. htmlgraph/builders/track.py +14 -0
  34. htmlgraph/cigs/__init__.py +4 -0
  35. htmlgraph/cigs/reporter.py +818 -0
  36. htmlgraph/cli.py +1427 -401
  37. htmlgraph/cli_commands/__init__.py +1 -0
  38. htmlgraph/cli_commands/feature.py +195 -0
  39. htmlgraph/cli_framework.py +115 -0
  40. htmlgraph/collections/__init__.py +2 -0
  41. htmlgraph/collections/base.py +21 -0
  42. htmlgraph/collections/session.py +189 -0
  43. htmlgraph/collections/spike.py +7 -1
  44. htmlgraph/collections/task_delegation.py +236 -0
  45. htmlgraph/collections/traces.py +482 -0
  46. htmlgraph/config.py +113 -0
  47. htmlgraph/converter.py +41 -0
  48. htmlgraph/cost_analysis/__init__.py +5 -0
  49. htmlgraph/cost_analysis/analyzer.py +438 -0
  50. htmlgraph/dashboard.html +3315 -492
  51. htmlgraph-0.24.2.data/data/htmlgraph/dashboard.html → htmlgraph/dashboard.html.backup +2246 -248
  52. htmlgraph/dashboard.html.bak +7181 -0
  53. htmlgraph/dashboard.html.bak2 +7231 -0
  54. htmlgraph/dashboard.html.bak3 +7232 -0
  55. htmlgraph/db/__init__.py +38 -0
  56. htmlgraph/db/queries.py +790 -0
  57. htmlgraph/db/schema.py +1334 -0
  58. htmlgraph/deploy.py +26 -27
  59. htmlgraph/docs/API_REFERENCE.md +841 -0
  60. htmlgraph/docs/HTTP_API.md +750 -0
  61. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  62. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +710 -0
  63. htmlgraph/docs/README.md +533 -0
  64. htmlgraph/docs/version_check.py +3 -1
  65. htmlgraph/error_handler.py +544 -0
  66. htmlgraph/event_log.py +2 -0
  67. htmlgraph/hooks/__init__.py +8 -0
  68. htmlgraph/hooks/bootstrap.py +169 -0
  69. htmlgraph/hooks/context.py +271 -0
  70. htmlgraph/hooks/drift_handler.py +521 -0
  71. htmlgraph/hooks/event_tracker.py +405 -15
  72. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  73. htmlgraph/hooks/pretooluse.py +476 -6
  74. htmlgraph/hooks/prompt_analyzer.py +648 -0
  75. htmlgraph/hooks/session_handler.py +583 -0
  76. htmlgraph/hooks/state_manager.py +501 -0
  77. htmlgraph/hooks/subagent_stop.py +309 -0
  78. htmlgraph/hooks/task_enforcer.py +39 -0
  79. htmlgraph/models.py +111 -15
  80. htmlgraph/operations/fastapi_server.py +230 -0
  81. htmlgraph/orchestration/headless_spawner.py +22 -14
  82. htmlgraph/pydantic_models.py +476 -0
  83. htmlgraph/quality_gates.py +350 -0
  84. htmlgraph/repo_hash.py +511 -0
  85. htmlgraph/sdk.py +348 -10
  86. htmlgraph/server.py +194 -0
  87. htmlgraph/session_hooks.py +300 -0
  88. htmlgraph/session_manager.py +131 -1
  89. htmlgraph/session_registry.py +587 -0
  90. htmlgraph/session_state.py +436 -0
  91. htmlgraph/system_prompts.py +449 -0
  92. htmlgraph/templates/orchestration-view.html +350 -0
  93. htmlgraph/track_builder.py +19 -0
  94. htmlgraph/validation.py +115 -0
  95. htmlgraph-0.25.0.data/data/htmlgraph/dashboard.html +7417 -0
  96. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/METADATA +91 -64
  97. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/RECORD +103 -42
  98. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/styles.css +0 -0
  99. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  100. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  101. {htmlgraph-0.24.2.data → htmlgraph-0.25.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  102. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/WHEEL +0 -0
  103. {htmlgraph-0.24.2.dist-info → htmlgraph-0.25.0.dist-info}/entry_points.txt +0 -0
htmlgraph/repo_hash.py ADDED
@@ -0,0 +1,511 @@
1
+ """
2
+ Repository Hashing and Git Awareness Module.
3
+
4
+ Provides stable repository identification and git state tracking for:
5
+ - Unique repo identification across machines/clones
6
+ - Stable hashes from: path + remote URL + inode
7
+ - Stability across branch changes
8
+ - Monorepo support (multiple projects = different hashes)
9
+ - Git state tracking: branch, commit, dirty flag
10
+
11
+ Architecture:
12
+ RepoHash(repo_path) → compute stable hash + git info
13
+
14
+ Hash inputs:
15
+ 1. Absolute repository path
16
+ 2. Git remote URL (if available)
17
+ 3. File system inode
18
+
19
+ Outputs:
20
+ - repo_hash: "repo-abc123def456" (stable, unique)
21
+ - git_info: {branch, commit, remote, dirty, last_commit_date}
22
+ - monorepo_project: "project-name" (if in monorepo)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import logging
29
+ import os
30
+ import subprocess
31
+ from datetime import datetime
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class RepoHash:
39
+ """
40
+ Generate stable hashes for git repositories.
41
+
42
+ Provides unique repository identification and git state tracking.
43
+ Hash is stable across branch changes and independent of file modifications.
44
+ """
45
+
46
+ def __init__(self, repo_path: Path | None = None):
47
+ """
48
+ Initialize with git repository path.
49
+
50
+ Args:
51
+ repo_path: Path to the git repository. Defaults to current directory.
52
+
53
+ Raises:
54
+ OSError: If path does not exist.
55
+ """
56
+ if repo_path is None:
57
+ repo_path = Path.cwd()
58
+ else:
59
+ repo_path = Path(repo_path)
60
+
61
+ if not repo_path.exists():
62
+ raise OSError(f"Repository path does not exist: {repo_path}")
63
+
64
+ self.repo_path = repo_path.resolve()
65
+ self._git_info_cache: dict[str, Any] | None = None
66
+ self._repo_hash_cache: str | None = None
67
+
68
+ def compute_repo_hash(self) -> str:
69
+ """
70
+ Compute stable hash from path + remote + inode.
71
+
72
+ Hash inputs:
73
+ 1. Absolute repo path
74
+ 2. Git remote URL (if available)
75
+ 3. File system inode
76
+
77
+ Returns:
78
+ Hex string like 'repo-abc123def456'
79
+
80
+ The hash is deterministic: same repo always produces same hash.
81
+ Branch changes do not affect the hash.
82
+ """
83
+ if self._repo_hash_cache is not None:
84
+ return self._repo_hash_cache
85
+
86
+ # Get hash inputs
87
+ path_str = str(self.repo_path.absolute())
88
+ remote = get_git_remote(self.repo_path)
89
+ inode = get_inode(self.repo_path)
90
+
91
+ # Compute hash
92
+ hash_input = compute_hash_inputs(path_str, remote, inode)
93
+ hash_hex = hashlib.sha256(hash_input.encode()).hexdigest()[:12]
94
+
95
+ result = f"repo-{hash_hex}"
96
+ self._repo_hash_cache = result
97
+ return result
98
+
99
+ def get_git_info(self) -> dict[str, Any]:
100
+ """
101
+ Get current git state.
102
+
103
+ Returns:
104
+ {
105
+ "branch": "main",
106
+ "commit": "d78e458abc123",
107
+ "remote": "https://github.com/user/repo.git",
108
+ "dirty": False,
109
+ "last_commit_date": "2026-01-08T12:34:56Z"
110
+ }
111
+
112
+ All fields present. Non-git repos return sensible defaults.
113
+ """
114
+ if self._git_info_cache is not None:
115
+ return self._git_info_cache
116
+
117
+ result: dict[str, Any] = {
118
+ "branch": get_current_branch(self.repo_path),
119
+ "commit": get_current_commit(self.repo_path),
120
+ "remote": get_git_remote(self.repo_path),
121
+ "dirty": is_git_dirty(self.repo_path),
122
+ "last_commit_date": get_last_commit_date(self.repo_path),
123
+ }
124
+
125
+ self._git_info_cache = result
126
+ return result
127
+
128
+ def is_monorepo(self) -> bool:
129
+ """
130
+ Detect if this is a monorepo structure.
131
+
132
+ Looks for:
133
+ - Multiple package.json files (npm/yarn monorepo)
134
+ - Multiple pyproject.toml files (Python monorepo)
135
+ - workspaces field in package.json
136
+
137
+ Returns:
138
+ True if monorepo structure detected, False otherwise.
139
+ """
140
+ return _detect_monorepo(self.repo_path)
141
+
142
+ def get_monorepo_project(self) -> str | None:
143
+ """
144
+ If monorepo, identify which project we're in.
145
+
146
+ Scans up from current directory to find workspace marker,
147
+ then identifies the project subdirectory.
148
+
149
+ Returns:
150
+ Project name (e.g., "packages/claude-plugin") or None if not in monorepo.
151
+ """
152
+ return _get_monorepo_project(self.repo_path)
153
+
154
+
155
+ # Module-level functions for git operations
156
+
157
+
158
+ def compute_hash_inputs(path: str, remote: str | None, inode: int) -> str:
159
+ """
160
+ Combine inputs into stable hash string.
161
+
162
+ Args:
163
+ path: Absolute repository path
164
+ remote: Git remote URL (optional)
165
+ inode: File system inode
166
+
167
+ Returns:
168
+ Combined hash input string
169
+ """
170
+ # Order matters for determinism
171
+ parts = [
172
+ f"path:{path}",
173
+ f"remote:{remote or 'none'}",
174
+ f"inode:{inode}",
175
+ ]
176
+ return "|".join(parts)
177
+
178
+
179
+ def get_git_remote(repo_path: Path | None = None) -> str | None:
180
+ """
181
+ Get primary git remote URL.
182
+
183
+ Attempts to get 'origin' remote, falls back to first available remote.
184
+
185
+ Args:
186
+ repo_path: Repository path. Defaults to current directory.
187
+
188
+ Returns:
189
+ Remote URL string or None if not a git repo.
190
+ """
191
+ if repo_path is None:
192
+ repo_path = Path.cwd()
193
+ else:
194
+ repo_path = Path(repo_path)
195
+
196
+ try:
197
+ # Try to get origin remote first
198
+ result = subprocess.run(
199
+ ["git", "-C", str(repo_path), "config", "--get", "remote.origin.url"],
200
+ capture_output=True,
201
+ text=True,
202
+ timeout=5,
203
+ )
204
+
205
+ if result.returncode == 0 and result.stdout.strip():
206
+ return result.stdout.strip()
207
+
208
+ # Fall back to first available remote
209
+ result = subprocess.run(
210
+ ["git", "-C", str(repo_path), "remote", "get-url", "origin"],
211
+ capture_output=True,
212
+ text=True,
213
+ timeout=5,
214
+ )
215
+
216
+ if result.returncode == 0 and result.stdout.strip():
217
+ return result.stdout.strip()
218
+
219
+ return None
220
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
221
+ logger.debug(f"Failed to get git remote for {repo_path}")
222
+ return None
223
+
224
+
225
+ def get_current_branch(repo_path: Path | None = None) -> str | None:
226
+ """
227
+ Get current git branch name.
228
+
229
+ Args:
230
+ repo_path: Repository path. Defaults to current directory.
231
+
232
+ Returns:
233
+ Branch name (e.g., "main") or None if not a git repo.
234
+ """
235
+ if repo_path is None:
236
+ repo_path = Path.cwd()
237
+ else:
238
+ repo_path = Path(repo_path)
239
+
240
+ try:
241
+ result = subprocess.run(
242
+ ["git", "-C", str(repo_path), "rev-parse", "--abbrev-ref", "HEAD"],
243
+ capture_output=True,
244
+ text=True,
245
+ timeout=5,
246
+ )
247
+
248
+ if result.returncode == 0:
249
+ branch = result.stdout.strip()
250
+ return branch if branch else None
251
+
252
+ return None
253
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
254
+ logger.debug(f"Failed to get current branch for {repo_path}")
255
+ return None
256
+
257
+
258
+ def get_current_commit(repo_path: Path | None = None) -> str | None:
259
+ """
260
+ Get current commit SHA (short form, 7 chars).
261
+
262
+ Args:
263
+ repo_path: Repository path. Defaults to current directory.
264
+
265
+ Returns:
266
+ Short commit SHA (e.g., "d78e458") or None if not a git repo.
267
+ """
268
+ if repo_path is None:
269
+ repo_path = Path.cwd()
270
+ else:
271
+ repo_path = Path(repo_path)
272
+
273
+ try:
274
+ result = subprocess.run(
275
+ [
276
+ "git",
277
+ "-C",
278
+ str(repo_path),
279
+ "rev-parse",
280
+ "--short=7",
281
+ "HEAD",
282
+ ],
283
+ capture_output=True,
284
+ text=True,
285
+ timeout=5,
286
+ )
287
+
288
+ if result.returncode == 0:
289
+ commit = result.stdout.strip()
290
+ return commit if commit else None
291
+
292
+ return None
293
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
294
+ logger.debug(f"Failed to get current commit for {repo_path}")
295
+ return None
296
+
297
+
298
+ def is_git_dirty(repo_path: Path | None = None) -> bool:
299
+ """
300
+ Check if repo has uncommitted changes.
301
+
302
+ Args:
303
+ repo_path: Repository path. Defaults to current directory.
304
+
305
+ Returns:
306
+ True if repo has uncommitted changes, False if clean or not a git repo.
307
+ """
308
+ if repo_path is None:
309
+ repo_path = Path.cwd()
310
+ else:
311
+ repo_path = Path(repo_path)
312
+
313
+ try:
314
+ # Check for staged or unstaged changes
315
+ result = subprocess.run(
316
+ ["git", "-C", str(repo_path), "status", "--porcelain"],
317
+ capture_output=True,
318
+ text=True,
319
+ timeout=5,
320
+ )
321
+
322
+ if result.returncode == 0:
323
+ # If there's any output, repo is dirty
324
+ return bool(result.stdout.strip())
325
+
326
+ return False
327
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
328
+ logger.debug(f"Failed to check git dirty status for {repo_path}")
329
+ return False
330
+
331
+
332
+ def get_last_commit_date(repo_path: Path | None = None) -> str | None:
333
+ """
334
+ Get last commit timestamp in ISO 8601 format.
335
+
336
+ Args:
337
+ repo_path: Repository path. Defaults to current directory.
338
+
339
+ Returns:
340
+ ISO 8601 timestamp (e.g., "2026-01-08T12:34:56Z") or None if not a git repo.
341
+ """
342
+ if repo_path is None:
343
+ repo_path = Path.cwd()
344
+ else:
345
+ repo_path = Path(repo_path)
346
+
347
+ try:
348
+ result = subprocess.run(
349
+ [
350
+ "git",
351
+ "-C",
352
+ str(repo_path),
353
+ "log",
354
+ "-1",
355
+ "--format=%ci",
356
+ "HEAD",
357
+ ],
358
+ capture_output=True,
359
+ text=True,
360
+ timeout=5,
361
+ )
362
+
363
+ if result.returncode == 0:
364
+ commit_date_str = result.stdout.strip()
365
+ if commit_date_str:
366
+ # Parse ISO format from git and ensure UTC timezone marker
367
+ try:
368
+ # Git returns: "2026-01-08 12:34:56 +0000"
369
+ # Parse by replacing space with T and removing timezone
370
+ dt_str = commit_date_str.split("+")[0].strip().replace(" ", "T")
371
+ dt = datetime.fromisoformat(dt_str)
372
+ # Format as UTC ISO 8601
373
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
374
+ except (ValueError, AttributeError, IndexError):
375
+ # Fallback: return as-is if parsing fails
376
+ return commit_date_str
377
+
378
+ return None
379
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
380
+ logger.debug(f"Failed to get last commit date for {repo_path}")
381
+ return None
382
+
383
+
384
+ def get_inode(path: Path) -> int:
385
+ """
386
+ Get file system inode for unique identification.
387
+
388
+ The inode is a unique identifier on the file system.
389
+ Different mount points can have different inodes for the same repository.
390
+
391
+ Args:
392
+ path: File system path.
393
+
394
+ Returns:
395
+ Inode number.
396
+
397
+ Raises:
398
+ OSError: If stat() fails.
399
+ """
400
+ try:
401
+ st = os.stat(path)
402
+ return st.st_ino
403
+ except OSError as e:
404
+ logger.error(f"Failed to get inode for {path}: {e}")
405
+ raise
406
+
407
+
408
+ # Monorepo detection helpers
409
+
410
+
411
+ def _detect_monorepo(repo_path: Path) -> bool:
412
+ """
413
+ Detect if repository is a monorepo.
414
+
415
+ Looks for:
416
+ - Multiple pyproject.toml files (Python monorepo)
417
+ - Multiple package.json files (npm/yarn monorepo)
418
+ - workspaces field in package.json
419
+
420
+ Args:
421
+ repo_path: Repository path.
422
+
423
+ Returns:
424
+ True if monorepo structure detected.
425
+ """
426
+ try:
427
+ # Check for Python monorepo (multiple pyproject.toml)
428
+ pyproject_files = list(repo_path.glob("**/pyproject.toml"))
429
+ if len(pyproject_files) > 1:
430
+ return True
431
+
432
+ # Check for npm monorepo (multiple package.json)
433
+ package_files = list(repo_path.glob("**/package.json"))
434
+ if len(package_files) > 1:
435
+ return True
436
+
437
+ # Check for workspaces in root package.json
438
+ root_package = repo_path / "package.json"
439
+ if root_package.exists():
440
+ try:
441
+ import json
442
+
443
+ with open(root_package) as f:
444
+ data = json.load(f)
445
+ if "workspaces" in data:
446
+ return True
447
+ except (json.JSONDecodeError, OSError):
448
+ pass
449
+
450
+ return False
451
+ except OSError:
452
+ logger.debug(f"Error detecting monorepo at {repo_path}")
453
+ return False
454
+
455
+
456
+ def _get_monorepo_project(repo_path: Path) -> str | None:
457
+ """
458
+ Identify which project we're in within a monorepo.
459
+
460
+ Scans up from repo_path to find workspace marker (pyproject.toml, package.json),
461
+ then returns relative path from workspace root to repo.
462
+
463
+ Args:
464
+ repo_path: Repository path (or subdirectory within monorepo).
465
+
466
+ Returns:
467
+ Relative path from monorepo root to project (e.g., "packages/claude-plugin")
468
+ or None if not in monorepo.
469
+ """
470
+ try:
471
+ # First, find monorepo root by scanning up from repo_path for .git
472
+ current = repo_path.resolve()
473
+ monorepo_root = None
474
+
475
+ while current != current.parent:
476
+ if (current / ".git").exists():
477
+ monorepo_root = current
478
+ break
479
+ current = current.parent
480
+
481
+ if monorepo_root is None:
482
+ # Not in a git repo or .git not found - not a monorepo context
483
+ return None
484
+
485
+ # Now check if this is actually a monorepo
486
+ if not _detect_monorepo(monorepo_root):
487
+ return None
488
+
489
+ # Find the project directory (first ancestor with pyproject.toml or package.json)
490
+ current = repo_path.resolve()
491
+ while current != current.parent:
492
+ if (current / "pyproject.toml").exists() or (
493
+ current / "package.json"
494
+ ).exists():
495
+ # Found project root, return relative path from monorepo root
496
+ try:
497
+ rel_path = current.relative_to(monorepo_root)
498
+ result = str(rel_path)
499
+ return result if result != "." else None
500
+ except ValueError:
501
+ return None
502
+
503
+ if current == monorepo_root:
504
+ # Reached monorepo root without finding project marker
505
+ break
506
+ current = current.parent
507
+
508
+ return None
509
+ except (OSError, ValueError):
510
+ logger.debug(f"Error identifying monorepo project at {repo_path}")
511
+ return None