diff --git a/skills/openclaw-audit/SKILL.md b/skills/openclaw-audit/SKILL.md new file mode 100644 index 0000000..6a0c5ca --- /dev/null +++ b/skills/openclaw-audit/SKILL.md @@ -0,0 +1,69 @@ +--- +name: openclaw-audit +description: Audit OpenClaw runtime logs, session transcripts, tool failures, and correlate errors with recent git commits across infrapuzzle, mop-skills, and knowledge-wiki. Use when performing system health audits, investigating tool failures, or running the weekly IT Guy self-audit. +--- + +# ๐Ÿฉบ OpenClaw System & Log Self-Audit (`openclaw-audit`) + +Comprehensive system diagnostic and correlation engine designed for **IT Guy** (`itguy`). It analyzes runtime container stdout logs, session transcripts, and tool execution failures across all agents, correlates errors with recent Git commits across the 3 core repositories, and files structured audit reports and actionable task queue entries into the Knowledge Wiki. + +## ๐ŸŽฏ When to Use +- **Weekly Scheduled Audit**: Triggered automatically via OpenClaw Cron (`itguy-weekly-health-audit`) every Sunday at 03:00 Europe/Berlin. +- **On-Demand Diagnostics**: Whenever the user asks: *"Prรผfe die Logs von OpenClaw"*, *"Gibt es Tool-Fehler?"*, *"Audit durchfรผhren"*, *"System-Health prรผfen"*. +- **Post-Deployment Validation**: After making configuration, skill, or manifest changes to verify whether errors appeared. + +--- + +## ๐Ÿ—‚๏ธ The Three Core Repositories Architecture +The audit correlates runtime behavior with recent git commits across three foundational repositories: + +1. **`infrapuzzle`** (`/home/node/infrapuzzle/infrapuzzle` or `/home/node/.openclaw/repos/infrapuzzle`): + - Scope: Cluster infrastructure, Kubespray bootstrap, K8s manifests, Terraform DNS, ESPHome IoT, ADRs. +2. **`mop-skills`** (`/home/node/mop-skills/mop-skills`): + - Scope: Custom Python skills, CLI utilities, and MCP server bridges. +3. **`knowledge-wiki`** (`/home/node/.openclaw/wiki/main`): + - Scope: Agents-as-code (`agents/*.md`), active task queue (`tasks/antigravity.md`), family knowledge vault, and audit archives (`operations/audits/`). + +--- + +## ๐Ÿ› ๏ธ CLI Operations Reference + +### 1. Standard Audit (Inspection Only) +```bash +uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d +``` +Fetches container logs from the last 7 days, parses session transcripts across all agents, summarizes recent commits from all 3 git repos, and outputs a formatted diagnostic markdown report to stdout. + +### 2. Full Audit with Wiki Archival & Task Filing +```bash +uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py \ + --since 7d \ + --write-report \ + --add-task \ + --git-push +``` +- **`--write-report`**: Writes the detailed diagnostic report to `/home/node/.openclaw/wiki/main/operations/audits/YYYY-Wxx.md`. +- **`--add-task`**: Appends any newly detected, deduplicated high/critical issues to `/home/node/.openclaw/wiki/main/tasks/antigravity.md` under `## ๐Ÿ“ฅ Pending Tasks`. +- **`--git-push`**: Commits and pushes the modified wiki pages directly to `git.moritzgraf.de/moritz/knowledge-wiki.git`. + +### 3. Machine-Readable JSON Output +```bash +uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 24h --json +``` +Returns a structured JSON payload containing `status`, `recent_commits`, `error_clusters`, `session_tool_failures`, and `affected_agents`. + +--- + +## ๐Ÿค– Protocol for IT Guy Agent (`itguy`) + +When executing the weekly audit turn: +1. Run the audit script: + ```bash + uv run /home/node/mop-skills/mop-skills/skills/openclaw-audit/scripts/audit_openclaw.py --since 7d --write-report --add-task --git-push + ``` +2. Review the aggregated output: + - Check if any new issues were added to `tasks/antigravity.md`. + - Correlate any error spikes with commits in `infrapuzzle` or `mop-skills`. +3. Delivery Decision: + - **If issues were found and tasks added**: Send a concise summary of findings, affected agents/tools, and links to the wiki task queue directly into Telegram Topic `#IT Guy` (thread `119`). + - **If zero issues / all healthy**: Respond with **`NO_REPLY`** to maintain intentional silence (no Telegram notification spam). diff --git a/skills/openclaw-audit/scripts/audit_openclaw.py b/skills/openclaw-audit/scripts/audit_openclaw.py new file mode 100755 index 0000000..8046686 --- /dev/null +++ b/skills/openclaw-audit/scripts/audit_openclaw.py @@ -0,0 +1,622 @@ +#!/usr/bin/env python3 +""" +OpenClaw System, Log & Repository Self-Audit Engine +Analyzes container logs, session transcripts, and correlates error events +with recent Git commits across infrapuzzle, mop-skills, and knowledge-wiki. +""" + +import argparse +import datetime +import json +import os +import re +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# Default search directories (in-cluster vs local fallback) +DEFAULT_CANDIDATE_PATHS = { + "infrapuzzle": [ + Path("/home/node/infrapuzzle/infrapuzzle"), + Path("/home/node/.openclaw/repos/infrapuzzle"), + Path("/home/node/infrapuzzle"), + Path("/Users/moritz/src/infrapuzzle"), + ], + "mop-skills": [ + Path("/home/node/mop-skills/mop-skills"), + Path("/Users/moritz/src/mop-skills"), + ], + "knowledge-wiki": [ + Path("/home/node/.openclaw/wiki/main"), + Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"), + ], +} + +DEFAULT_SESSIONS_DIRS = [ + Path("/home/node/.openclaw/agents"), + Path.home() / ".openclaw/agents", +] + + +def resolve_repo_path(repo_name: str, override: Optional[str] = None) -> Optional[Path]: + if override: + p = Path(override) + if p.exists() and (p / ".git").exists(): + return p + for candidate in DEFAULT_CANDIDATE_PATHS.get(repo_name, []): + if candidate.exists() and (candidate / ".git").exists(): + return candidate + return None + + +def resolve_sessions_dir(override: Optional[str] = None) -> Optional[Path]: + if override: + p = Path(override) + if p.exists(): + return p + for candidate in DEFAULT_SESSIONS_DIRS: + if candidate.exists(): + return candidate + return None + + +def resolve_wiki_dir(override: Optional[str] = None) -> Optional[Path]: + return resolve_repo_path("knowledge-wiki", override) + + +def run_cmd(cmd: List[str], cwd: Optional[Path] = None) -> Tuple[int, str, str]: + try: + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=120, + ) + return proc.returncode, proc.stdout, proc.stderr + except Exception as e: + return -1, "", str(e) + + +def get_git_commits(repo_path: Path, since: str) -> List[Dict[str, str]]: + if not repo_path or not repo_path.exists(): + return [] + # format: hash | iso_date | author | subject + cmd = [ + "git", + "-C", + str(repo_path), + "log", + f"--since={since}", + '--pretty=format:%h%x09%ad%x09%an%x09%s', + "--date=iso-strict", + "-n", + "20", + ] + rc, stdout, _ = run_cmd(cmd) + if rc != 0 or not stdout.strip(): + return [] + + commits = [] + for line in stdout.strip().splitlines(): + parts = line.split("\t") + if len(parts) >= 4: + commits.append({ + "hash": parts[0], + "date": parts[1], + "author": parts[2], + "subject": parts[3], + }) + return commits + + +def normalize_error(msg: str) -> str: + """Normalize error strings to group identical issues together.""" + # Strip ISO timestamps + s = re.sub(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)?", "", msg) + # Strip UUIDs + s = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "", s) + # Strip toolCallIds and runIds + s = re.sub(r"(call_[0-9a-zA-Z]+|runId=[0-9a-zA-Z-]+)", "", s) + # Strip memory addresses + s = re.sub(r"0x[0-9a-fA-F]+", "", s) + # Strip excessive whitespace + s = " ".join(s.split()) + return s[:250] + + +def fetch_container_logs(since: str, tail: int = 25000) -> str: + """Fetch container stdout logs using kubectl or read from openclaw log files.""" + # 1. Try in-cluster kubectl directly + kubectl_bin = "/home/node/.openclaw/bin/kubectl" if Path("/home/node/.openclaw/bin/kubectl").exists() else "kubectl" + cmd = [ + kubectl_bin, + "logs", + f"--since={since}", + f"--tail={tail}", + "-n", + "openclaw", + "openclaw-0", + "-c", + "openclaw", + ] + rc, stdout, _ = run_cmd(cmd) + if rc == 0 and stdout.strip(): + return stdout + + # 2. Fallback: Check local logs directory + local_log_dir = Path("/home/node/.openclaw/logs") + if local_log_dir.exists(): + combined = [] + for log_file in local_log_dir.glob("*.log"): + try: + combined.append(log_file.read_text(errors="replace")) + except Exception: + pass + if combined: + return "\n".join(combined) + + return "" + + +def parse_container_logs(raw_logs: str) -> List[Dict[str, Any]]: + """Parse and cluster errors found in container stdout logs.""" + error_clusters: Dict[str, Dict[str, Any]] = defaultdict(lambda: { + "signature": "", + "category": "GATEWAY", + "count": 0, + "first_seen": None, + "last_seen": None, + "sample": "", + }) + + if not raw_logs: + return [] + + lines = raw_logs.splitlines() + for line in lines: + line_lower = line.lower() + + # False positive exclusions + if "iserror=false" in line_lower: + continue + if "[model-fetch] start" in line_lower: + continue + if "timeoutms=undefined" in line_lower and "status=200" in line_lower: + continue + if "response provider=" in line_lower and "status=200" in line_lower: + continue + + is_error = False + category = "GATEWAY" + + # Categorization patterns + if "iserror=true" in line_lower or "tool failed" in line_lower or "[embedded run tool end: " in line and "error" in line_lower: + is_error = True + category = "TOOL" + elif "mcp" in line_lower and ("exit" in line_lower or "crashed" in line_lower or "closed with code" in line_lower or "failed to start" in line_lower): + is_error = True + category = "MCP_SERVER" + elif "unhandledrejection" in line_lower or "uncaughtexception" in line_lower or "traceback (most recent call last)" in line_lower or "fatal error" in line_lower: + is_error = True + category = "RUNTIME_EXCEPTION" + elif any(f"status={code}" in line for code in ["500", "502", "503", "504", "429"]) or ("model-fetch" in line_lower and ("failed" in line_lower or "timed out" in line_lower)): + is_error = True + category = "LLM_PROVIDER" + elif " [error] " in line_lower or "level=error" in line_lower or "econnrefused" in line_lower or "etimedout" in line_lower: + is_error = True + category = "SYSTEM_ERROR" + + if is_error: + # Extract timestamp if present at beginning of line + ts_match = re.match(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s]*)", line) + ts = ts_match.group(1) if ts_match else "" + + sig = normalize_error(line) + cluster = error_clusters[sig] + cluster["signature"] = sig + cluster["category"] = category + cluster["count"] += 1 + cluster["sample"] = line[:400] + if ts: + if not cluster["first_seen"] or ts < cluster["first_seen"]: + cluster["first_seen"] = ts + if not cluster["last_seen"] or ts > cluster["last_seen"]: + cluster["last_seen"] = ts + + # Convert to sorted list by frequency + sorted_clusters = sorted(error_clusters.values(), key=lambda x: x["count"], reverse=True) + return sorted_clusters + + +def parse_session_transcripts(sessions_dir: Path, since_hours: float = 168.0) -> List[Dict[str, Any]]: + """Scan and parse JSONL session transcripts for tool errors.""" + if not sessions_dir or not sessions_dir.exists(): + return [] + + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=since_hours) + tool_failures: Dict[str, Dict[str, Any]] = defaultdict(lambda: { + "agent": "", + "tool": "", + "signature": "", + "count": 0, + "sample": "", + "first_seen": None, + "last_seen": None, + }) + + # Sessions are stored at /sessions/*.jsonl + for jsonl_file in sessions_dir.glob("*/sessions/*.jsonl"): + try: + mtime = datetime.datetime.fromtimestamp(jsonl_file.stat().st_mtime, tz=datetime.timezone.utc) + if mtime < cutoff: + continue + + agent_name = jsonl_file.parent.parent.name + + with open(jsonl_file, "r", encoding="utf-8", errors="replace") as f: + for line in f: + if not line.strip(): + continue + try: + record = json.loads(line) + except Exception: + continue + + # Check for tool errors in various OpenClaw transcript representations + role = record.get("role") + msg_type = record.get("type") + is_err = record.get("isError", False) + content = record.get("content", "") + + if isinstance(content, list): + content_str = json.dumps(content) + else: + content_str = str(content) + + # Detect failure markers + has_error_keyword = any(k in content_str.lower() for k in [ + "tool failed", + "error:", + "traceback", + "validationerror", + "status code 5", + "connection refused", + "timeout", + ]) + + if role == "tool" and (is_err or has_error_keyword): + tool_name = record.get("name") or record.get("tool") or "unknown_tool" + sig = f"[{agent_name}] {tool_name}: {normalize_error(content_str)}" + entry = tool_failures[sig] + entry["agent"] = agent_name + entry["tool"] = tool_name + entry["signature"] = sig + entry["count"] += 1 + entry["sample"] = content_str[:300] + ts = record.get("createdAt") or record.get("timestamp") or mtime.isoformat() + if not entry["first_seen"] or ts < entry["first_seen"]: + entry["first_seen"] = ts + if not entry["last_seen"] or ts > entry["last_seen"]: + entry["last_seen"] = ts + except Exception: + continue + + return sorted(tool_failures.values(), key=lambda x: x["count"], reverse=True) + + +def build_audit_report( + since: str, + repos_commits: Dict[str, List[Dict[str, str]]], + container_errors: List[Dict[str, Any]], + session_errors: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Compile structured audit report from all inputs.""" + now = datetime.datetime.now(datetime.timezone.utc) + year, week, _ = now.isocalendar() + + total_container_errors = sum(c["count"] for c in container_errors) + total_session_tool_errors = sum(s["count"] for s in session_errors) + + # Determine severity + has_critical = any(c["category"] in ["MCP_SERVER", "RUNTIME_EXCEPTION"] and c["count"] >= 3 for c in container_errors) + has_high = total_session_tool_errors > 5 or total_container_errors > 20 + + if has_critical: + status = "CRITICAL" + elif has_high: + status = "DEGRADED" + elif total_container_errors > 0 or total_session_tool_errors > 0: + status = "WARNING" + else: + status = "HEALTHY" + + return { + "year": year, + "week": week, + "generated_at": now.isoformat(), + "since": since, + "status": status, + "total_container_errors": total_container_errors, + "total_session_tool_errors": total_session_tool_errors, + "repos_commits": repos_commits, + "container_errors": container_errors, + "session_errors": session_errors, + } + + +def render_markdown_report(report: Dict[str, Any]) -> str: + """Format audit report into clean Markdown with YAML frontmatter.""" + year = report["year"] + week = report["week"] + status = report["status"] + status_emoji = { + "HEALTHY": "๐ŸŸข", + "WARNING": "๐ŸŸก", + "DEGRADED": "๐ŸŸ ", + "CRITICAL": "๐Ÿ”ด", + }.get(status, "โšช") + + lines = [ + "---", + f'title: "Weekly OpenClaw System Audit {year}-W{week:02d}"', + f'date: "{report["generated_at"]}"', + f'status: "{status.lower()}"', + f'evaluated_window: "{report["since"]}"', + "type: audit-report", + "---", + "", + f"# {status_emoji} OpenClaw System Health Audit โ€” {year}-W{week:02d}", + "", + f"- **Generated At**: `{report['generated_at']}`", + f"- **Audit Window**: Last `{report['since']}`", + f"- **Overall System State**: **{status}**", + f"- **Container Log Errors**: `{report['total_container_errors']}` occurrences", + f"- **Session Tool Failures**: `{report['total_session_tool_errors']}` occurrences", + "", + "---", + "", + "## ๐Ÿ—‚๏ธ Recent Repository Commits Timeline", + "", + "Cross-repository correlation of recent code and configuration modifications:", + "", + ] + + repos = report.get("repos_commits", {}) + for repo_name, commits in repos.items(): + lines.append(f"### ๐Ÿ“ฆ `{repo_name}` ({len(commits)} commits)") + if not commits: + lines.append("_No commits within this audit window._\n") + continue + lines.append("| Hash | Date | Author | Message |") + lines.append("|---|---|---|---|") + for c in commits[:8]: + msg = c['subject'].replace("|", "\\|") + lines.append(f"| `{c['hash']}` | {c['date'][:10]} | {c['author']} | {msg} |") + lines.append("") + + lines.extend([ + "---", + "", + "## ๐Ÿ› ๏ธ Session Transcript Tool Failures", + "", + "Tool invocation errors across subagent conversations (`/home/node/.openclaw/agents/*/sessions/`):", + "", + ]) + + session_errs = report.get("session_errors", []) + if not session_errs: + lines.append("โœ… **No tool failures detected in agent session transcripts.**\n") + else: + lines.append("| Agent | Tool | Count | Sample Error |") + lines.append("|---|---|---|---|") + for s in session_errs[:15]: + sample_clean = s['sample'].replace("|", "\\|").replace("\n", " ")[:120] + lines.append(f"| `{s['agent']}` | `{s['tool']}` | **{s['count']}** | `{sample_clean}` |") + lines.append("") + + lines.extend([ + "---", + "", + "## ๐Ÿ–ฅ๏ธ Container Runtime & Gateway Log Anomalies", + "", + "Aggregated errors from OpenClaw pod stdout (`kubectl logs -n openclaw openclaw-0 -c openclaw`):", + "", + ]) + + container_errs = report.get("container_errors", []) + if not container_errs: + lines.append("โœ… **No runtime exceptions or container errors detected.**\n") + else: + lines.append("| Category | Count | Normalized Signature | Sample |") + lines.append("|---|---|---|---|") + for c in container_errs[:15]: + sig_clean = c['signature'].replace("|", "\\|").replace("\n", " ")[:100] + sample_clean = c['sample'].replace("|", "\\|").replace("\n", " ")[:100] + lines.append(f"| `{c['category']}` | **{c['count']}** | `{sig_clean}` | `{sample_clean}` |") + lines.append("") + + lines.extend([ + "---", + "", + "## ๐ŸŽฏ Actionable Antigravity Recommendations", + "", + ]) + + if status == "HEALTHY": + lines.append("All services, MCP bridges, and subagent tools operate within healthy parameters. No remediation required.") + else: + lines.append("The following candidate fixes should be investigated and resolved:") + # List top issues + for idx, s in enumerate(session_errs[:3], start=1): + lines.append(f"{idx}. **Fix Tool Failure in `{s['agent']}` / `{s['tool']}`**: `{s['signature'][:140]}`") + for idx, c in enumerate(container_errs[:3], start=len(session_errs[:3]) + 1): + lines.append(f"{idx}. **Resolve `{c['category']}` Anomaly**: `{c['signature'][:140]}`") + + lines.append("") + return "\n".join(lines) + + +def update_tasks_antigravity(wiki_dir: Path, report: Dict[str, Any]) -> List[str]: + """Append actionable issues to tasks/antigravity.md under ## ๐Ÿ“ฅ Pending Tasks.""" + tasks_file = wiki_dir / "tasks" / "antigravity.md" + if not tasks_file.exists(): + return [] + + try: + content = tasks_file.read_text(encoding="utf-8") + except Exception as e: + print(f"Error reading {tasks_file}: {e}", file=sys.stderr) + return [] + + added_tasks = [] + today = datetime.datetime.now().strftime("%Y-%m-%d") + + # Combine top session and container errors + top_candidates = [] + for s in report.get("session_errors", []): + if s["count"] >= 2: + top_candidates.append({ + "title": f"Fix Tool Failure: {s['agent']} / {s['tool']}", + "detail": f"Observed {s['count']} times in session transcripts. Sample: {s['sample'][:250]}", + "key": f"{s['agent']}:{s['tool']}", + }) + + for c in report.get("container_errors", []): + if c["count"] >= 3 and c["category"] in ["MCP_SERVER", "RUNTIME_EXCEPTION", "TOOL"]: + top_candidates.append({ + "title": f"Resolve {c['category']} Error in OpenClaw Gateway", + "detail": f"Observed {c['count']} times in pod logs. Signature: {c['signature'][:200]}", + "key": c["signature"][:40], + }) + + # Only append if not already mentioned in tasks file + pending_anchor = "## ๐Ÿ“ฅ Pending Tasks\n" + if pending_anchor not in content: + return [] + + new_task_blocks = [] + for cand in top_candidates: + # Check deduplication + if cand["title"] in content or cand["key"] in content: + continue + + block = ( + f"- [ ] **{today}: {cand['title']}**\n" + f" - **Details**: Detected by IT Guy weekly self-audit (`audit_openclaw.py`). {cand['detail']}\n" + f" - **Target**: OpenClaw Gateway / MCP / Subagent configuration\n" + f" - **Requested by**: IT Guy Agent (Autonomous Weekly Audit)\n" + ) + new_task_blocks.append(block) + added_tasks.append(cand["title"]) + + if new_task_blocks: + parts = content.split(pending_anchor, 1) + updated_content = parts[0] + pending_anchor + "\n" + "\n".join(new_task_blocks) + "\n" + parts[1] + tasks_file.write_text(updated_content, encoding="utf-8") + + return added_tasks + + +def git_commit_and_push(wiki_dir: Path, commit_msg: str) -> bool: + """Commit and push changes in knowledge-wiki repository.""" + rc1, _, _ = run_cmd(["git", "-C", str(wiki_dir), "add", "-A"]) + if rc1 != 0: + return False + rc2, _, _ = run_cmd(["git", "-C", str(wiki_dir), "commit", "-m", commit_msg]) + # rc 1 can mean nothing to commit + rc3, _, _ = run_cmd(["git", "-C", str(wiki_dir), "push", "origin", "main"]) + return rc3 == 0 + + +def main(): + parser = argparse.ArgumentParser(description="OpenClaw System, Log & Repository Self-Audit Engine") + parser.add_argument("--since", default="7d", help="Audit time window (e.g. 7d, 24h, 168h)") + parser.add_argument("--write-report", action="store_true", help="Save markdown report to operations/audits/ in knowledge-wiki") + parser.add_argument("--add-task", action="store_true", help="Append new actionable issues to tasks/antigravity.md") + parser.add_argument("--git-push", action="store_true", help="Commit and push changes to knowledge-wiki git remote") + parser.add_argument("--json", action="store_true", help="Output machine-readable JSON to stdout") + parser.add_argument("--infrapuzzle-path", help="Path to infrapuzzle repo override") + parser.add_argument("--mop-skills-path", help="Path to mop-skills repo override") + parser.add_argument("--wiki-path", help="Path to knowledge-wiki repo override") + parser.add_argument("--sessions-dir", help="Path to OpenClaw sessions directory override") + args = parser.parse_args() + + # Convert since string to hours for file timestamps + since_hours = 168.0 + if args.since.endswith("d"): + since_hours = float(args.since[:-1]) * 24.0 + elif args.since.endswith("h"): + since_hours = float(args.since[:-1]) + + # 1. Resolve Repositories + repos = { + "infrapuzzle": resolve_repo_path("infrapuzzle", args.infrapuzzle_path), + "mop-skills": resolve_repo_path("mop-skills", getattr(args, "mop_skills_path", None)), + "knowledge-wiki": resolve_wiki_dir(args.wiki_path), + } + + # 2. Correlate Git Commits + repos_commits = {} + for r_name, r_path in repos.items(): + if r_path: + repos_commits[r_name] = get_git_commits(r_path, args.since) + else: + repos_commits[r_name] = [] + + # 3. Ingest Container Logs + raw_logs = fetch_container_logs(args.since) + container_errors = parse_container_logs(raw_logs) + + # 4. Ingest Session Transcripts + sessions_dir = resolve_sessions_dir(args.sessions_dir) + session_errors = parse_session_transcripts(sessions_dir, since_hours) if sessions_dir else [] + + # 5. Build Report + report = build_audit_report(args.since, repos_commits, container_errors, session_errors) + + # 6. JSON output mode + if args.json: + print(json.dumps(report, indent=2)) + return + + # 7. Render Markdown + md_report = render_markdown_report(report) + + # 8. Write Report if requested + wiki_dir = repos.get("knowledge-wiki") + if args.write_report and wiki_dir: + audits_dir = wiki_dir / "operations" / "audits" + audits_dir.mkdir(parents=True, exist_ok=True) + report_path = audits_dir / f"{report['year']}-W{report['week']:02d}.md" + report_path.write_text(md_report, encoding="utf-8") + print(f"โœ… Audit report saved: {report_path}") + + # 9. Append to tasks/antigravity.md if requested + added_tasks = [] + if args.add_task and wiki_dir: + added_tasks = update_tasks_antigravity(wiki_dir, report) + if added_tasks: + print(f"๐Ÿ“‹ Added {len(added_tasks)} new task(s) to tasks/antigravity.md:") + for t in added_tasks: + print(f" - {t}") + else: + print("๐Ÿ“‹ No new tasks required (all issues healthy or already queued).") + + # 10. Commit & Push if requested + if args.git_push and wiki_dir and (args.write_report or added_tasks): + success = git_commit_and_push(wiki_dir, f"chore(audit): weekly health audit {report['year']}-W{report['week']:02d}") + if success: + print("๐Ÿš€ Pushed audit report and task updates to git.moritzgraf.de!") + else: + print("โš ๏ธ Git push skipped or failed (check credentials/remote).") + + # Print summary to stdout + print(md_report) + + +if __name__ == "__main__": + main()