feat: initial Argus implementation

- CLI with notification, activity, and escalation commands
- SQLite database with schema
- Datasette + static HTML dashboard
- Systemd service for background operation
- Install/uninstall scripts
- SKILL.md and procedures for sub-agent usage
This commit is contained in:
Zeus 2026-02-22 10:25:46 +00:00
commit 90361fbe16
12 changed files with 1608 additions and 0 deletions

17
CHANGELOG.md Normal file
View file

@ -0,0 +1,17 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-02-22
### Added
- Initial release
- CLI with notification, activity, and escalation commands
- SQLite database for persistent state
- Datasette integration for API access
- Static HTML dashboard
- Systemd service for background operation
- Install/uninstall scripts

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Zeus @ Olymp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

148
README.md Normal file
View file

@ -0,0 +1,148 @@
# Argus 👁️
CLI tool for GitHub/Forgejo community awareness — tracking notifications, logging activity, managing escalations.
Named after **Argus Panoptes**, the all-seeing giant of Greek mythology.
## Features
- **Notification triage** — Pull, act, or dismiss GitHub notifications
- **Activity logging** — Track community actions
- **Escalation management** — Flag and track issues needing attention
- **Dashboard** — Visual overview via Datasette + static HTML
- **Systemd service** — Run in background
## Quick Start
```bash
# Install
./scripts/install.sh
# Initialize database
argus init
# Set up GitHub token
export GH_TOKEN=your_github_token
# Pull notifications
argus notif pull
# Check status
argus status
# Start dashboard
systemctl start argus
```
## Commands
### Core
| Command | Description |
|---------|-------------|
| `argus init` | Create database |
| `argus version` | Show version |
| `argus status` | Summary overview |
### Notifications
| Command | Description |
|---------|-------------|
| `argus notif pull [--dry-run]` | Fetch from GitHub |
| `argus notif list [--pending]` | List notifications |
| `argus notif act <id> <action>` | Mark as acted |
| `argus notif dismiss <id> [reason]` | Dismiss |
| `argus notif audit` | Auto-escalate stale |
### Activity
| Command | Description |
|---------|-------------|
| `argus activity log <action> <detail>` | Log action |
| `argus activity list` | Show recent |
### Escalations
| Command | Description |
|---------|-------------|
| `argus escalate create --category --title` | Create |
| `argus escalate list [--status]` | List |
| `argus escalate ack <id>` | Acknowledge |
| `argus escalate resolve <id> --by --resolution` | Resolve |
### Service
| Command | Description |
|---------|-------------|
| `argus serve` | Start Datasette + dashboard |
## Configuration
Environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `ARGUS_DB` | `~/.argus/argus.db` | Database path |
| `ARGUS_PORT` | `8100` | Datasette API port |
| `ARGUS_DASHBOARD_PORT` | `8101` | Dashboard port |
| `GH_TOKEN` | — | GitHub API token |
## Architecture
```
┌─────────────────────────────────────────────────┐
│ argus CLI │
│ notif pull | activity log | escalate | serve │
└─────────────────┬───────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ ~/.argus/argus.db │
│ repos | issues | notifications | activity │
└─────────────────┬───────────────────────────────┘
┌─────────┴─────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Datasette │ │ Dashboard │
│ :8100 (API) │ │ :8101 (HTML) │
└───────────────┘ └───────────────┘
```
## Installation
### Prerequisites
- Python 3.9+
- pip
- gh CLI (for GitHub integration)
### Install
```bash
git clone https://forgejo.tail593e12.ts.net/Zeus/argus.git /opt/argus
cd /opt/argus && ./scripts/install.sh
```
### Uninstall
```bash
/opt/argus/scripts/uninstall.sh
```
## Systemd Service
```bash
systemctl enable argus # Start on boot
systemctl start argus # Start now
systemctl status argus # Check status
journalctl -u argus -f # View logs
```
## License
MIT
## Author
Zeus @ Olymp

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0

635
bin/argus Executable file
View file

@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""
Argus - CLI tool for GitHub/Forgejo community awareness.
Named after Argus Panoptes, the all-seeing giant of Greek mythology.
"""
import argparse
import json
import os
import sqlite3
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
# ============================================================================
# Configuration
# ============================================================================
VERSION = "0.1.0"
DEFAULT_DB_PATH = Path.home() / ".argus" / "argus.db"
DEFAULT_PORT = 8100
DEFAULT_DASHBOARD_PORT = 8101
def get_db_path() -> Path:
return Path(os.environ.get("ARGUS_DB", DEFAULT_DB_PATH))
def get_schema_path() -> Path:
# Check relative to script first (for development)
script_dir = Path(__file__).parent.parent
schema_path = script_dir / "lib" / "schema.sql"
if schema_path.exists():
return schema_path
# Fall back to installed location
return Path("/opt/argus/lib/schema.sql")
def get_dashboard_path() -> Path:
script_dir = Path(__file__).parent.parent
dashboard_path = script_dir / "dashboard" / "index.html"
if dashboard_path.exists():
return dashboard_path
return Path("/opt/argus/dashboard/index.html")
# ============================================================================
# Database
# ============================================================================
def get_db() -> sqlite3.Connection:
db_path = get_db_path()
if not db_path.exists():
print(f"Error: Database not found at {db_path}", file=sys.stderr)
print("Run 'argus init' first.", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
db_path = get_db_path()
db_path.parent.mkdir(parents=True, exist_ok=True)
schema_path = get_schema_path()
if not schema_path.exists():
print(f"Error: Schema not found at {schema_path}", file=sys.stderr)
sys.exit(1)
conn = sqlite3.connect(db_path)
with open(schema_path) as f:
conn.executescript(f.read())
conn.close()
print(f"✓ Initialized database at {db_path}")
# ============================================================================
# GitHub API
# ============================================================================
def get_gh_token() -> Optional[str]:
return os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
def gh_api(endpoint: str) -> dict:
"""Call GitHub API via gh CLI."""
try:
result = subprocess.run(
["gh", "api", endpoint],
capture_output=True,
text=True,
check=True
)
return json.loads(result.stdout)
except subprocess.CalledProcessError as e:
print(f"Error calling GitHub API: {e.stderr}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError:
print(f"Error parsing GitHub API response", file=sys.stderr)
sys.exit(1)
# ============================================================================
# Commands: Core
# ============================================================================
def cmd_init(args) -> None:
"""Initialize database."""
init_db()
def cmd_version(args) -> None:
"""Show version."""
print(f"argus {VERSION}")
def cmd_status(args) -> None:
"""Show status summary."""
conn = get_db()
# Pending notifications
pending = conn.execute(
"SELECT COUNT(*) FROM notifications WHERE dismissed = 0 AND acted_at IS NULL"
).fetchone()[0]
# Recent activity (last 24h)
yesterday = (datetime.now() - timedelta(days=1)).isoformat()
recent_activity = conn.execute(
"SELECT COUNT(*) FROM activity_log WHERE timestamp > ?",
(yesterday,)
).fetchone()[0]
# Open escalations
open_escalations = conn.execute(
"SELECT COUNT(*) FROM escalations WHERE status = 'open'"
).fetchone()[0]
# Last poll
last_poll = conn.execute(
"SELECT polled_at, new_count FROM notification_polls ORDER BY id DESC LIMIT 1"
).fetchone()
print("=" * 50)
print("ARGUS STATUS")
print("=" * 50)
print(f"Pending notifications: {pending}")
print(f"Activity (24h): {recent_activity}")
print(f"Open escalations: {open_escalations}")
if last_poll:
print(f"Last poll: {last_poll['polled_at']} ({last_poll['new_count']} new)")
else:
print("Last poll: Never")
print("=" * 50)
conn.close()
# ============================================================================
# Commands: Notifications
# ============================================================================
def cmd_notif_pull(args) -> None:
"""Pull notifications from GitHub."""
conn = get_db()
# Fetch notifications from GitHub
notifications = gh_api("notifications")
new_count = 0
for notif in notifications:
notif_id = notif["id"]
# Check if already exists
existing = conn.execute(
"SELECT id FROM notifications WHERE id = ?",
(notif_id,)
).fetchone()
if existing:
continue
# Extract repo info
repo_full = notif["repository"]["full_name"]
reason = notif["reason"]
title = notif["subject"]["title"]
url = notif["subject"]["url"] or ""
# Convert API URL to web URL
web_url = url.replace("api.github.com/repos", "github.com")
web_url = web_url.replace("/pulls/", "/pull/")
if args.dry_run:
print(f"[DRY-RUN] Would add: {title} ({reason})")
new_count += 1
continue
conn.execute("""
INSERT INTO notifications (id, repo_id, reason, title, url, seen_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (notif_id, repo_full, reason, title, web_url, datetime.now().isoformat()))
new_count += 1
if not args.dry_run:
# Record poll
conn.execute(
"INSERT INTO notification_polls (new_count) VALUES (?)",
(new_count,)
)
conn.commit()
conn.close()
if args.dry_run:
print(f"\n[DRY-RUN] Would add {new_count} notifications")
else:
print(f"✓ Pulled {new_count} new notifications")
def cmd_notif_list(args) -> None:
"""List notifications."""
conn = get_db()
query = "SELECT * FROM notifications"
conditions = []
params = []
if args.pending:
conditions.append("dismissed = 0 AND acted_at IS NULL")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY seen_at DESC"
if args.limit:
query += f" LIMIT {args.limit}"
notifications = conn.execute(query, params).fetchall()
if not notifications:
print("No notifications found.")
return
for n in notifications:
status = "✓" if n["acted_at"] else ("✗" if n["dismissed"] else "○")
print(f"{status} [{n['id'][:8]}] {n['title'][:60]}")
print(f" Repo: {n['repo_id']} | Reason: {n['reason']}")
if n["url"]:
print(f" URL: {n['url']}")
print()
conn.close()
def cmd_notif_act(args) -> None:
"""Mark notification as acted on."""
conn = get_db()
# Find notification by prefix match
notif = conn.execute(
"SELECT * FROM notifications WHERE id LIKE ?",
(f"{args.id}%",)
).fetchone()
if not notif:
print(f"Error: Notification not found: {args.id}", file=sys.stderr)
sys.exit(1)
action = args.action
detail = args.detail or ""
conn.execute("""
UPDATE notifications
SET acted_at = ?, action_taken = ?
WHERE id = ?
""", (datetime.now().isoformat(), f"{action}: {detail}".strip(": "), notif["id"]))
# Log the activity
conn.execute("""
INSERT INTO activity_log (issue_id, action, detail, github_url)
VALUES (?, ?, ?, ?)
""", (notif.get("issue_id"), action, detail or notif["title"], notif.get("url")))
conn.commit()
conn.close()
print(f"✓ Marked notification as acted: {action}")
def cmd_notif_dismiss(args) -> None:
"""Dismiss notification."""
conn = get_db()
# Find notification by prefix match
notif = conn.execute(
"SELECT * FROM notifications WHERE id LIKE ?",
(f"{args.id}%",)
).fetchone()
if not notif:
print(f"Error: Notification not found: {args.id}", file=sys.stderr)
sys.exit(1)
reason = args.reason or "dismissed"
conn.execute("""
UPDATE notifications
SET dismissed = 1, action_taken = ?
WHERE id = ?
""", (f"dismissed: {reason}", notif["id"]))
conn.commit()
conn.close()
print(f"✓ Dismissed notification: {reason}")
def cmd_notif_audit(args) -> None:
"""Audit stale notifications and create escalations."""
conn = get_db()
stale_hours = args.stale_hours or 48
cutoff = (datetime.now() - timedelta(hours=stale_hours)).isoformat()
stale = conn.execute("""
SELECT * FROM notifications
WHERE dismissed = 0 AND acted_at IS NULL AND seen_at < ?
""", (cutoff,)).fetchall()
if not stale:
print("✓ No stale notifications found")
return
print(f"Found {len(stale)} stale notifications (>{stale_hours}h):")
for n in stale:
print(f" - {n['title'][:50]}...")
# Create escalation
conn.execute("""
INSERT INTO escalations (category, title, detail)
VALUES (?, ?, ?)
""", (
"stale-notification",
f"Stale: {n['title'][:100]}",
f"Notification {n['id']} has been pending for >{stale_hours}h.\nURL: {n.get('url', 'N/A')}"
))
conn.commit()
conn.close()
print(f"\n✓ Created {len(stale)} escalations")
# ============================================================================
# Commands: Activity
# ============================================================================
def cmd_activity_log(args) -> None:
"""Log an activity."""
conn = get_db()
conn.execute("""
INSERT INTO activity_log (issue_id, action, detail)
VALUES (?, ?, ?)
""", (args.issue, args.action, args.detail))
conn.commit()
conn.close()
print(f"✓ Logged: {args.action}")
def cmd_activity_list(args) -> None:
"""List recent activity."""
conn = get_db()
limit = args.limit or 20
activities = conn.execute("""
SELECT * FROM activity_log
ORDER BY timestamp DESC
LIMIT ?
""", (limit,)).fetchall()
if not activities:
print("No activity found.")
return
for a in activities:
ts = a["timestamp"][:16].replace("T", " ")
issue = f"[{a['issue_id']}] " if a["issue_id"] else ""
print(f"{ts} | {issue}{a['action']}: {a['detail'] or ''}")
conn.close()
# ============================================================================
# Commands: Escalations
# ============================================================================
def cmd_escalate_create(args) -> None:
"""Create an escalation."""
conn = get_db()
conn.execute("""
INSERT INTO escalations (category, priority, title, detail)
VALUES (?, ?, ?, ?)
""", (args.category, args.priority or "normal", args.title, args.detail))
conn.commit()
conn.close()
print(f"✓ Created escalation: {args.title}")
def cmd_escalate_list(args) -> None:
"""List escalations."""
conn = get_db()
query = "SELECT * FROM escalations"
params = []
if args.status:
query += " WHERE status = ?"
params.append(args.status)
query += " ORDER BY created_at DESC"
escalations = conn.execute(query, params).fetchall()
if not escalations:
print("No escalations found.")
return
for e in escalations:
status_icon = "🔴" if e["status"] == "open" else "✅"
priority_icon = "⚠️" if e["priority"] == "high" else ""
print(f"{status_icon} [{e['id']}] {priority_icon}{e['title']}")
print(f" Category: {e['category']} | Created: {e['created_at'][:10]}")
if e["status"] == "resolved":
print(f" Resolved by: {e['resolved_by']} | {e['resolution']}")
print()
conn.close()
def cmd_escalate_ack(args) -> None:
"""Acknowledge an escalation."""
conn = get_db()
conn.execute("""
UPDATE escalations
SET status = 'acknowledged'
WHERE id = ?
""", (args.id,))
if conn.total_changes == 0:
print(f"Error: Escalation not found: {args.id}", file=sys.stderr)
sys.exit(1)
conn.commit()
conn.close()
print(f"✓ Acknowledged escalation #{args.id}")
def cmd_escalate_resolve(args) -> None:
"""Resolve an escalation."""
conn = get_db()
conn.execute("""
UPDATE escalations
SET status = 'resolved', resolved_at = ?, resolved_by = ?, resolution = ?
WHERE id = ?
""", (datetime.now().isoformat(), args.by, args.resolution, args.id))
if conn.total_changes == 0:
print(f"Error: Escalation not found: {args.id}", file=sys.stderr)
sys.exit(1)
conn.commit()
conn.close()
print(f"✓ Resolved escalation #{args.id}")
# ============================================================================
# Commands: Serve
# ============================================================================
def cmd_serve(args) -> None:
"""Start datasette + dashboard servers."""
import http.server
import threading
db_path = get_db_path()
dashboard_path = get_dashboard_path()
port = args.port or int(os.environ.get("ARGUS_PORT", DEFAULT_PORT))
dashboard_port = args.dashboard_port or int(os.environ.get("ARGUS_DASHBOARD_PORT", DEFAULT_DASHBOARD_PORT))
print(f"Starting Argus servers...")
print(f" Datasette: http://localhost:{port}")
print(f" Dashboard: http://localhost:{dashboard_port}")
print()
# Start datasette in background
datasette_proc = subprocess.Popen(
["datasette", str(db_path), "-p", str(port), "--cors"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# Start simple HTTP server for dashboard
os.chdir(dashboard_path.parent)
handler = http.server.SimpleHTTPRequestHandler
with http.server.HTTPServer(("", dashboard_port), handler) as httpd:
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
datasette_proc.terminate()
# ============================================================================
# Main
# ============================================================================
def main():
parser = argparse.ArgumentParser(
prog="argus",
description="CLI tool for GitHub/Forgejo community awareness"
)
subparsers = parser.add_subparsers(dest="command", help="Commands")
# Core commands
subparsers.add_parser("init", help="Initialize database")
subparsers.add_parser("version", help="Show version")
subparsers.add_parser("status", help="Show status summary")
# Notification commands
notif_parser = subparsers.add_parser("notif", help="Notification management")
notif_sub = notif_parser.add_subparsers(dest="notif_cmd")
pull_parser = notif_sub.add_parser("pull", help="Pull notifications from GitHub")
pull_parser.add_argument("--dry-run", action="store_true", help="Show what would be pulled")
list_parser = notif_sub.add_parser("list", help="List notifications")
list_parser.add_argument("--pending", action="store_true", help="Show only pending")
list_parser.add_argument("--limit", type=int, help="Limit results")
act_parser = notif_sub.add_parser("act", help="Mark as acted on")
act_parser.add_argument("id", help="Notification ID (prefix match)")
act_parser.add_argument("action", help="Action taken")
act_parser.add_argument("detail", nargs="?", help="Additional detail")
dismiss_parser = notif_sub.add_parser("dismiss", help="Dismiss notification")
dismiss_parser.add_argument("id", help="Notification ID (prefix match)")
dismiss_parser.add_argument("reason", nargs="?", help="Dismissal reason")
audit_parser = notif_sub.add_parser("audit", help="Audit stale notifications")
audit_parser.add_argument("--stale-hours", type=int, help="Hours to consider stale (default: 48)")
# Activity commands
activity_parser = subparsers.add_parser("activity", help="Activity logging")
activity_sub = activity_parser.add_subparsers(dest="activity_cmd")
log_parser = activity_sub.add_parser("log", help="Log an activity")
log_parser.add_argument("action", help="Action type")
log_parser.add_argument("detail", help="Action detail")
log_parser.add_argument("--issue", help="Related issue ID")
list_activity_parser = activity_sub.add_parser("list", help="List recent activity")
list_activity_parser.add_argument("--limit", type=int, help="Limit results")
# Escalation commands
escalate_parser = subparsers.add_parser("escalate", help="Escalation management")
escalate_sub = escalate_parser.add_subparsers(dest="escalate_cmd")
create_parser = escalate_sub.add_parser("create", help="Create escalation")
create_parser.add_argument("--category", required=True, help="Escalation category")
create_parser.add_argument("--title", required=True, help="Escalation title")
create_parser.add_argument("--detail", help="Additional detail")
create_parser.add_argument("--priority", choices=["low", "normal", "high"], help="Priority")
list_esc_parser = escalate_sub.add_parser("list", help="List escalations")
list_esc_parser.add_argument("--status", choices=["open", "acknowledged", "resolved"], help="Filter by status")
ack_parser = escalate_sub.add_parser("ack", help="Acknowledge escalation")
ack_parser.add_argument("id", type=int, help="Escalation ID")
resolve_parser = escalate_sub.add_parser("resolve", help="Resolve escalation")
resolve_parser.add_argument("id", type=int, help="Escalation ID")
resolve_parser.add_argument("--by", required=True, help="Who resolved")
resolve_parser.add_argument("--resolution", required=True, help="Resolution description")
# Serve command
serve_parser = subparsers.add_parser("serve", help="Start servers")
serve_parser.add_argument("--port", type=int, help=f"Datasette port (default: {DEFAULT_PORT})")
serve_parser.add_argument("--dashboard-port", type=int, help=f"Dashboard port (default: {DEFAULT_DASHBOARD_PORT})")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(0)
# Route to command handlers
if args.command == "init":
cmd_init(args)
elif args.command == "version":
cmd_version(args)
elif args.command == "status":
cmd_status(args)
elif args.command == "notif":
if args.notif_cmd == "pull":
cmd_notif_pull(args)
elif args.notif_cmd == "list":
cmd_notif_list(args)
elif args.notif_cmd == "act":
cmd_notif_act(args)
elif args.notif_cmd == "dismiss":
cmd_notif_dismiss(args)
elif args.notif_cmd == "audit":
cmd_notif_audit(args)
else:
notif_parser.print_help()
elif args.command == "activity":
if args.activity_cmd == "log":
cmd_activity_log(args)
elif args.activity_cmd == "list":
cmd_activity_list(args)
else:
activity_parser.print_help()
elif args.command == "escalate":
if args.escalate_cmd == "create":
cmd_escalate_create(args)
elif args.escalate_cmd == "list":
cmd_escalate_list(args)
elif args.escalate_cmd == "ack":
cmd_escalate_ack(args)
elif args.escalate_cmd == "resolve":
cmd_escalate_resolve(args)
else:
escalate_parser.print_help()
elif args.command == "serve":
cmd_serve(args)
else:
parser.print_help()
if __name__ == "__main__":
main()

349
dashboard/index.html Normal file
View file

@ -0,0 +1,349 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Argus Dashboard</title>
<style>
:root {
--bg: #1a1a2e;
--card-bg: #16213e;
--accent: #e94560;
--text: #eee;
--text-dim: #888;
--success: #4ade80;
--warning: #fbbf24;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 2rem;
min-height: 100vh;
}
h1 {
margin-bottom: 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
h1 span {
font-size: 2rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
}
.card h2 {
font-size: 1rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1rem;
}
.stat {
font-size: 3rem;
font-weight: bold;
}
.stat.warning { color: var(--warning); }
.stat.success { color: var(--success); }
.stat.accent { color: var(--accent); }
.stat-label {
color: var(--text-dim);
margin-top: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
th {
color: var(--text-dim);
font-weight: 500;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
tr:hover {
background: rgba(255,255,255,0.05);
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.status-pending { background: var(--warning); color: #000; }
.status-acted { background: var(--success); color: #000; }
.status-dismissed { background: var(--text-dim); color: #000; }
.status-open { background: var(--accent); }
.status-resolved { background: var(--success); color: #000; }
.loading {
text-align: center;
color: var(--text-dim);
padding: 2rem;
}
.error {
background: rgba(233, 69, 96, 0.2);
border: 1px solid var(--accent);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.refresh-btn {
background: var(--accent);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
float: right;
}
.refresh-btn:hover {
opacity: 0.9;
}
.timestamp {
color: var(--text-dim);
font-size: 0.85rem;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.section {
margin-bottom: 2rem;
}
.section h2 {
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
</head>
<body>
<h1><span>👁️</span> Argus Dashboard</h1>
<div id="error" class="error" style="display: none;"></div>
<div class="grid" id="stats">
<div class="card">
<h2>Pending Notifications</h2>
<div class="stat warning" id="pending-count">-</div>
<div class="stat-label">Awaiting action</div>
</div>
<div class="card">
<h2>Activity (24h)</h2>
<div class="stat success" id="activity-count">-</div>
<div class="stat-label">Actions logged</div>
</div>
<div class="card">
<h2>Open Escalations</h2>
<div class="stat accent" id="escalation-count">-</div>
<div class="stat-label">Need attention</div>
</div>
</div>
<div class="section">
<div class="card">
<h2>
Recent Notifications
<button class="refresh-btn" onclick="loadData()">↻ Refresh</button>
</h2>
<div id="notifications-table">
<div class="loading">Loading...</div>
</div>
</div>
</div>
<div class="section">
<div class="card">
<h2>Recent Activity</h2>
<div id="activity-table">
<div class="loading">Loading...</div>
</div>
</div>
</div>
<div class="section">
<div class="card">
<h2>Open Escalations</h2>
<div id="escalations-table">
<div class="loading">Loading...</div>
</div>
</div>
</div>
<script>
const API_BASE = 'http://localhost:8100';
async function fetchData(table, sql) {
const url = `${API_BASE}/argus.json?sql=${encodeURIComponent(sql)}&_shape=array`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`API error: ${resp.status}`);
return await resp.json();
}
function formatDate(iso) {
if (!iso) return '-';
const d = new Date(iso);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
}
function getNotificationStatus(row) {
if (row.dismissed) return { class: 'status-dismissed', text: 'Dismissed' };
if (row.acted_at) return { class: 'status-acted', text: 'Acted' };
return { class: 'status-pending', text: 'Pending' };
}
async function loadData() {
const errorEl = document.getElementById('error');
errorEl.style.display = 'none';
try {
// Stats
const pending = await fetchData('notifications',
'SELECT COUNT(*) as c FROM notifications WHERE dismissed = 0 AND acted_at IS NULL');
document.getElementById('pending-count').textContent = pending[0]?.c || 0;
const yesterday = new Date(Date.now() - 86400000).toISOString();
const activity = await fetchData('activity_log',
`SELECT COUNT(*) as c FROM activity_log WHERE timestamp > '${yesterday}'`);
document.getElementById('activity-count').textContent = activity[0]?.c || 0;
const escalations = await fetchData('escalations',
"SELECT COUNT(*) as c FROM escalations WHERE status = 'open'");
document.getElementById('escalation-count').textContent = escalations[0]?.c || 0;
// Notifications table
const notifications = await fetchData('notifications',
'SELECT * FROM notifications ORDER BY seen_at DESC LIMIT 10');
if (notifications.length === 0) {
document.getElementById('notifications-table').innerHTML =
'<div class="loading">No notifications yet.</div>';
} else {
let html = `<table>
<thead><tr><th>Status</th><th>Title</th><th>Repo</th><th>Seen</th></tr></thead>
<tbody>`;
for (const n of notifications) {
const status = getNotificationStatus(n);
const title = n.url ? `<a href="${n.url}" target="_blank">${n.title}</a>` : n.title;
html += `<tr>
<td><span class="status-badge ${status.class}">${status.text}</span></td>
<td>${title}</td>
<td>${n.repo_id || '-'}</td>
<td class="timestamp">${formatDate(n.seen_at)}</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('notifications-table').innerHTML = html;
}
// Activity table
const activityLog = await fetchData('activity_log',
'SELECT * FROM activity_log ORDER BY timestamp DESC LIMIT 10');
if (activityLog.length === 0) {
document.getElementById('activity-table').innerHTML =
'<div class="loading">No activity yet.</div>';
} else {
let html = `<table>
<thead><tr><th>Time</th><th>Action</th><th>Detail</th></tr></thead>
<tbody>`;
for (const a of activityLog) {
const detail = a.github_url ?
`<a href="${a.github_url}" target="_blank">${a.detail || '-'}</a>` :
(a.detail || '-');
html += `<tr>
<td class="timestamp">${formatDate(a.timestamp)}</td>
<td>${a.action}</td>
<td>${detail}</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('activity-table').innerHTML = html;
}
// Escalations table
const escList = await fetchData('escalations',
"SELECT * FROM escalations WHERE status = 'open' ORDER BY created_at DESC LIMIT 10");
if (escList.length === 0) {
document.getElementById('escalations-table').innerHTML =
'<div class="loading">No open escalations. ✨</div>';
} else {
let html = `<table>
<thead><tr><th>ID</th><th>Category</th><th>Title</th><th>Created</th></tr></thead>
<tbody>`;
for (const e of escList) {
html += `<tr>
<td>#${e.id}</td>
<td>${e.category}</td>
<td>${e.title}</td>
<td class="timestamp">${formatDate(e.created_at)}</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('escalations-table').innerHTML = html;
}
} catch (err) {
errorEl.textContent = `Failed to load data: ${err.message}. Is the Datasette server running on port 8100?`;
errorEl.style.display = 'block';
}
}
// Load on page load
loadData();
// Auto-refresh every 60 seconds
setInterval(loadData, 60000);
</script>
</body>
</html>

97
docs/SKILL.md Normal file
View file

@ -0,0 +1,97 @@
---
name: argus
description: "GitHub/Forgejo community awareness tool. TRIGGERS: notifications, community triage, PR reviews, issue tracking, escalations. Use when managing GitHub notifications, logging community actions, or tracking escalations."
---
# Argus Skill
CLI tool for GitHub/Forgejo community awareness — tracking notifications, logging activity, managing escalations.
## Quick Start
```bash
# Initialize database
argus init
# Pull notifications from GitHub
argus notif pull
# View status
argus status
# Start dashboard
argus serve
```
## Commands
### Status & Info
```bash
argus version # Show version
argus status # Summary: pending, activity, escalations
```
### Notifications
```bash
argus notif pull [--dry-run] # Fetch from GitHub API
argus notif list [--pending] # List notifications
argus notif act <id> <action> # Mark as acted
argus notif dismiss <id> [reason] # Dismiss
argus notif audit [--stale-hours] # Auto-escalate stale
```
### Activity Logging
```bash
argus activity log <action> <detail> [--issue ID]
argus activity list [--limit N]
```
### Escalations
```bash
argus escalate create --category CAT --title "..."
argus escalate list [--status open]
argus escalate ack <id>
argus escalate resolve <id> --by WHO --resolution "..."
```
### Service
```bash
argus serve [--port 8100] [--dashboard-port 8101]
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `ARGUS_DB` | `~/.argus/argus.db` | Database path |
| `ARGUS_PORT` | `8100` | Datasette port |
| `ARGUS_DASHBOARD_PORT` | `8101` | Dashboard port |
| `GH_TOKEN` | — | GitHub API token |
## Workflow
1. **Pull notifications:** `argus notif pull`
2. **Review pending:** `argus notif list --pending`
3. **Act or dismiss:** `argus notif act <id> reviewed` or `argus notif dismiss <id> duplicate`
4. **Log activity:** `argus activity log commented "Answered question"`
5. **Check dashboard:** Open http://localhost:8101
## Sub-Agent Usage
For automated community management:
```
Read notifications with argus notif list --pending.
For each notification:
- If PR: review changes, comment if needed
- If issue: assess priority, respond or escalate
- Log action with argus activity log
After processing: argus notif audit
```
See `procedures/subagent-completion.md` for detailed patterns.

View file

@ -0,0 +1,120 @@
# Argus Sub-Agent Procedures
Standard procedures for AI agents performing community management tasks.
## Notification Triage
### Input
- Pending notifications from `argus notif list --pending`
### Process
1. **Classify** each notification:
- `review-request`: Someone requested your review
- `mention`: You were mentioned
- `assign`: Issue/PR was assigned to you
- `comment`: New comment on subscribed thread
- `ci-failure`: CI/CD failure notification
2. **Prioritize**:
- P1 (immediate): Security issues, prod failures
- P2 (same day): Review requests, direct mentions
- P3 (when possible): General comments, updates
3. **Act**:
- For each notification, either:
- Take action (review, respond, fix)
- Escalate (create escalation)
- Dismiss (with reason)
### Output
```bash
# After processing each notification
argus notif act <id> <action> "<brief detail>"
# Or dismiss
argus notif dismiss <id> "<reason>"
# Log activity
argus activity log <action> "<detail>" [--issue <id>]
```
## Pull Request Review
### Input
- PR notification or explicit request
### Process
1. **Read PR**: Check title, description, changes
2. **Assess scope**: Is this a minor fix or major change?
3. **Review code**: Look for issues, suggest improvements
4. **Leave feedback**: Comment on GitHub
5. **Log action**: Record what you did
### Output
```bash
argus notif act <id> reviewed "Approved with comments"
argus activity log pr-review "Reviewed PR #123: Added caching layer"
```
## Escalation Handling
### When to Escalate
- Notification pending > 48 hours
- Security-related issues
- Unclear requirements needing human decision
- Permission/access issues
### Process
```bash
# Create escalation
argus escalate create \
--category "stale-notification" \
--title "PR #123 needs maintainer input" \
--priority high
# After human acknowledges
argus escalate ack <id>
# After resolution
argus escalate resolve <id> \
--by "k9ert" \
--resolution "Merged after fixes"
```
## Daily Routine
Suggested daily workflow for automated community management:
```bash
# Morning
argus notif pull
argus status
argus notif list --pending --limit 20
# Process notifications (triage, respond, escalate)
# ... actions ...
# End of day
argus notif audit --stale-hours 48
argus activity list --limit 10
```
## Reporting
For summaries:
```sql
-- Via datasette or direct SQL
SELECT action, COUNT(*)
FROM activity_log
WHERE timestamp > date('now', '-7 days')
GROUP BY action;
```
Dashboard at http://localhost:8101 provides visual overview.

81
lib/schema.sql Normal file
View file

@ -0,0 +1,81 @@
-- Argus Database Schema
-- Version: 0.1.0
-- Repositories being tracked
CREATE TABLE IF NOT EXISTS repos (
id TEXT PRIMARY KEY, -- owner/repo
description TEXT,
is_primary INTEGER DEFAULT 0,
last_synced_at TEXT
);
-- Issues and PRs
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY, -- owner/repo#number
repo_id TEXT NOT NULL,
number INTEGER NOT NULL,
type TEXT NOT NULL, -- issue | pr
title TEXT NOT NULL,
state TEXT NOT NULL, -- open | closed | merged
author TEXT,
labels TEXT DEFAULT '[]',
created_at TEXT,
updated_at TEXT,
FOREIGN KEY (repo_id) REFERENCES repos(id)
);
-- GitHub notifications
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
issue_id TEXT,
repo_id TEXT,
reason TEXT,
title TEXT,
url TEXT,
seen_at TEXT,
acted_at TEXT,
action_taken TEXT,
dismissed INTEGER DEFAULT 0,
FOREIGN KEY (issue_id) REFERENCES issues(id),
FOREIGN KEY (repo_id) REFERENCES repos(id)
);
-- Activity log
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT DEFAULT (datetime('now')),
issue_id TEXT,
action TEXT NOT NULL,
detail TEXT,
github_url TEXT,
FOREIGN KEY (issue_id) REFERENCES issues(id)
);
-- Escalations
CREATE TABLE IF NOT EXISTS escalations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT DEFAULT (datetime('now')),
category TEXT NOT NULL,
priority TEXT DEFAULT 'normal',
title TEXT NOT NULL,
detail TEXT,
status TEXT DEFAULT 'open',
resolved_at TEXT,
resolved_by TEXT,
resolution TEXT
);
-- Poll history
CREATE TABLE IF NOT EXISTS notification_polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
polled_at TEXT DEFAULT (datetime('now')),
new_count INTEGER DEFAULT 0
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_notifications_dismissed ON notifications(dismissed);
CREATE INDEX IF NOT EXISTS idx_notifications_acted ON notifications(acted_at);
CREATE INDEX IF NOT EXISTS idx_activity_timestamp ON activity_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_escalations_status ON escalations(status);
CREATE INDEX IF NOT EXISTS idx_issues_state ON issues(state);
CREATE INDEX IF NOT EXISTS idx_issues_repo ON issues(repo_id);

80
scripts/install.sh Executable file
View file

@ -0,0 +1,80 @@
#!/bin/bash
#
# Argus Installation Script
#
set -e
INSTALL_DIR="/opt/argus"
SKILL_LINK="/olymp/shared/skills/argus"
echo "╔════════════════════════════════════════╗"
echo "║ Argus Installation ║"
echo "╚════════════════════════════════════════╝"
echo
# Check dependencies
echo "Checking dependencies..."
if ! command -v python3 &> /dev/null; then
echo "Error: python3 is required but not installed."
exit 1
fi
if ! command -v gh &> /dev/null; then
echo "Warning: gh CLI not found. 'argus notif pull' will not work."
fi
# Install datasette if not present
if ! command -v datasette &> /dev/null; then
echo "Installing datasette..."
pip3 install datasette --quiet
fi
# Clone or update repo
if [ -d "$INSTALL_DIR" ]; then
echo "Updating existing installation..."
cd "$INSTALL_DIR"
git pull --quiet
else
echo "Installing to $INSTALL_DIR..."
# If running from repo directory
if [ -f "bin/argus" ]; then
cp -r . "$INSTALL_DIR"
else
echo "Error: Run this script from the argus repository directory."
exit 1
fi
fi
# Create symlink for CLI
echo "Creating CLI symlink..."
ln -sf "$INSTALL_DIR/bin/argus" /usr/local/bin/argus
# Install systemd service
echo "Installing systemd service..."
cp "$INSTALL_DIR/systemd/argus.service" /etc/systemd/system/
systemctl daemon-reload
# Create skill symlink if /olymp/shared/skills exists
if [ -d "/olymp/shared/skills" ]; then
echo "Creating skill symlink..."
ln -sf "$INSTALL_DIR/docs" "$SKILL_LINK"
fi
# Initialize database if not exists
if [ ! -f "$HOME/.argus/argus.db" ]; then
echo "Initializing database..."
argus init
fi
echo
echo "✓ Installation complete!"
echo
echo "Next steps:"
echo " 1. Set up GitHub token: export GH_TOKEN=your_token"
echo " 2. Pull notifications: argus notif pull"
echo " 3. Start dashboard: systemctl start argus"
echo
echo "Dashboard will be available at:"
echo " - Datasette API: http://localhost:8100"
echo " - Dashboard: http://localhost:8101"

44
scripts/uninstall.sh Executable file
View file

@ -0,0 +1,44 @@
#!/bin/bash
#
# Argus Uninstallation Script
#
set -e
INSTALL_DIR="/opt/argus"
SKILL_LINK="/olymp/shared/skills/argus"
echo "╔════════════════════════════════════════╗"
echo "║ Argus Uninstallation ║"
echo "╚════════════════════════════════════════╝"
echo
# Stop and disable service
echo "Stopping argus service..."
systemctl stop argus 2>/dev/null || true
systemctl disable argus 2>/dev/null || true
# Remove systemd service
echo "Removing systemd service..."
rm -f /etc/systemd/system/argus.service
systemctl daemon-reload
# Remove CLI symlink
echo "Removing CLI symlink..."
rm -f /usr/local/bin/argus
# Remove skill symlink
echo "Removing skill symlink..."
rm -f "$SKILL_LINK"
# Remove installation directory
echo "Removing installation directory..."
rm -rf "$INSTALL_DIR"
echo
echo "✓ Uninstallation complete!"
echo
echo "Note: User data at ~/.argus/ was preserved."
echo "To remove it: rm -rf ~/.argus"
echo
echo "Datasette was not uninstalled."
echo "To remove it: pip3 uninstall datasette"

15
systemd/argus.service Normal file
View file

@ -0,0 +1,15 @@
[Unit]
Description=Argus Community Dashboard
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/argus serve
Restart=on-failure
RestartSec=5
Environment=ARGUS_DB=/root/.argus/argus.db
Environment=ARGUS_PORT=8100
Environment=ARGUS_DASHBOARD_PORT=8101
[Install]
WantedBy=multi-user.target