1fedd683e0
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
177 lines
5.2 KiB
Python
177 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
dev_session_doc.py – Dokumentiert die zuletzt abgeschlossene Timetrack-Session in DEVLOG.md.
|
||
Wird automatisch vom Claude Code Stop-Hook aufgerufen.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
from datetime import datetime, timezone
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
DATA_FILE = os.path.expanduser("~/.claude/timetrack.json")
|
||
DEVLOG = os.path.join(BASE_DIR, "DEVLOG.md")
|
||
BACKEND_DIR = os.path.join(BASE_DIR, "backend")
|
||
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
def parse_iso(s: str) -> datetime:
|
||
if s.endswith("Z"):
|
||
s = s[:-1] + "+00:00"
|
||
return datetime.fromisoformat(s)
|
||
|
||
|
||
def format_duration(seconds: float) -> str:
|
||
seconds = int(seconds)
|
||
h = seconds // 3600
|
||
m = (seconds % 3600) // 60
|
||
if h > 0:
|
||
return f"{h}h {m:02d}m"
|
||
return f"{m}m"
|
||
|
||
|
||
def local_display(dt: datetime) -> str:
|
||
return dt.astimezone().strftime("%H:%M")
|
||
|
||
|
||
def local_date(dt: datetime) -> str:
|
||
return dt.astimezone().strftime("%Y-%m-%d")
|
||
|
||
|
||
def run(cmd: list[str]) -> str:
|
||
"""Run a command and return stdout; return '' on any error."""
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=15,
|
||
)
|
||
return result.stdout.strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
# ── Core logic ────────────────────────────────────────────────────────────────
|
||
|
||
def get_last_completed_session(data: dict) -> dict | None:
|
||
"""Return the most recently *completed* session (end != None)."""
|
||
completed = [s for s in data.get("sessions", []) if s.get("end") is not None]
|
||
if not completed:
|
||
return None
|
||
return max(completed, key=lambda s: s["end"])
|
||
|
||
|
||
def get_commits(start_iso: str, end_iso: str) -> list[str]:
|
||
"""Return list of 'HASH message' strings for commits within the session window."""
|
||
start_dt = parse_iso(start_iso)
|
||
end_dt = parse_iso(end_iso)
|
||
|
||
# git log uses local times via --after/--before; pass UTC explicitly as ISO
|
||
after = start_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
before = end_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
raw = run([
|
||
"git", "-C", BACKEND_DIR,
|
||
"log", "--oneline",
|
||
f"--after={after}",
|
||
f"--before={before}",
|
||
])
|
||
if not raw:
|
||
return []
|
||
return [line for line in raw.splitlines() if line.strip()]
|
||
|
||
|
||
def get_diff_stat() -> list[str]:
|
||
"""Return lines from git diff --stat HEAD~1 HEAD."""
|
||
raw = run([
|
||
"git", "-C", BACKEND_DIR,
|
||
"diff", "--stat", "HEAD~1", "HEAD",
|
||
])
|
||
if not raw:
|
||
return []
|
||
lines = raw.splitlines()
|
||
# Drop the summary line (last line with "N files changed …")
|
||
return [l for l in lines if l.strip() and " | " in l]
|
||
|
||
|
||
def build_entry(session: dict) -> str:
|
||
start_dt = parse_iso(session["start"])
|
||
end_dt = parse_iso(session["end"])
|
||
elapsed = (end_dt - start_dt).total_seconds()
|
||
duration = format_duration(elapsed)
|
||
|
||
date_str = local_date(start_dt)
|
||
start_str = local_display(start_dt)
|
||
end_str = local_display(end_dt)
|
||
desc = session.get("description") or "Claude Code Session"
|
||
|
||
commits = get_commits(session["start"], session["end"])
|
||
diff_stat = get_diff_stat()
|
||
|
||
lines: list[str] = []
|
||
lines.append(f"## {date_str} {start_str} – {end_str} ({duration})")
|
||
lines.append(f"**Beschreibung:** {desc}")
|
||
lines.append("")
|
||
|
||
# Commits section
|
||
lines.append("### Commits")
|
||
if commits:
|
||
for c in commits:
|
||
lines.append(f"- {c}")
|
||
else:
|
||
lines.append("Keine Commits in dieser Session.")
|
||
lines.append("")
|
||
|
||
# Changed files section
|
||
lines.append("### Geänderte Dateien")
|
||
if diff_stat:
|
||
for l in diff_stat:
|
||
# Normalize: " path/file | 5 ++-" → "- path/file (+5/-2)"
|
||
# Keep it simple and just relay the stat line
|
||
lines.append(f"- {l.strip()}")
|
||
else:
|
||
lines.append("Keine Änderungen ermittelbar.")
|
||
lines.append("")
|
||
lines.append("---")
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def append_to_devlog(entry: str) -> None:
|
||
# Ensure file exists with a header
|
||
if not os.path.exists(DEVLOG):
|
||
with open(DEVLOG, "w", encoding="utf-8") as f:
|
||
f.write("# TimeMaster – Dev Log\n\n")
|
||
|
||
with open(DEVLOG, "a", encoding="utf-8") as f:
|
||
f.write(entry)
|
||
|
||
|
||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||
|
||
def main() -> None:
|
||
if not os.path.exists(DATA_FILE):
|
||
return # Nothing to document
|
||
|
||
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
|
||
session = get_last_completed_session(data)
|
||
if not session:
|
||
return
|
||
|
||
entry = build_entry(session)
|
||
append_to_devlog(entry)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
main()
|
||
except Exception:
|
||
# Never crash Claude Code
|
||
pass
|