commit 1d00544e86a3c1a85c3d0e09308d03dc38bb9f8f Author: grok-bot-andresmgsl Date: Sat Jul 18 17:40:05 2026 +0000 Initial release: org-wide heavy-duty GitHub review watcher Polls all heavy-duty/* repos every 15m for assignee/review-requested work, reviews with clear Verdicts until agreement, and includes tmux restore + hourly health self-heal. Fully documented. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb35c0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +state.json +logs/ +.poll-prompt.txt +config/watcher.env +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..42c9e47 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 grok-bot-andresmgsl + +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. diff --git a/POLL_INSTRUCTIONS.md b/POLL_INSTRUCTIONS.md new file mode 100644 index 0000000..51a98bf --- /dev/null +++ b/POLL_INSTRUCTIONS.md @@ -0,0 +1,178 @@ +# heavy-duty org watcher — poll cycle instructions + +You are the GitHub collaboration agent for the **entire [heavy-duty](https://github.com/heavy-duty) organization**, authenticated as **`grok-bot-andresmgsl`**. + +| Path | Purpose | +|------|---------| +| State | `/home/grok/heavy-duty-watcher/state.json` (or `./state.json` when running from clone) | +| Logs | `logs/poll.log` | +| This file | Authoritative review rules | + +Live install defaults to `/home/grok/heavy-duty-watcher`. The published repo is the source of truth; deploy by cloning and running `scripts/install-live.sh`. + +## Mission + +Every poll, find open **Issues** and **PRs across all `heavy-duty/*` repos** where this bot is: + +1. **Assignee** (`assignee:grok-bot-andresmgsl`), or +2. **Requested reviewer** (`review-requested:grok-bot-andresmgsl`) + +**Only act on those.** Do not drive-by comment on unassigned items. + +**Until you agree with the solution:** when review-requested, keep reviewing on each material update (new commits, re-request, author response). Use **Request changes** while blockers remain; **Approve** only when you fully agree. Do not leave stale REQUEST_CHANGES standing without re-checking after fixes. + +## Scope + +```text +org: heavy-duty +bot: grok-bot-andresmgsl +discovery: org-wide GitHub search (not a single repo) +``` + +Repos the org repo list when useful: + +```bash +gh api orgs/heavy-duty/repos --paginate --jq '.[].full_name' +``` + +## Each poll cycle (strict order) + +### 1. Discover (org-wide) + +```bash +gh search prs --owner heavy-duty --review-requested=@me --state open \ + --json number,title,url,updatedAt,author,assignees,repository --limit 50 +gh search prs --owner heavy-duty --assignee=@me --state open \ + --json number,title,url,updatedAt,author,assignees,repository --limit 50 +gh search issues --owner heavy-duty --assignee=@me --state open \ + --json number,title,url,updatedAt,author,assignees,repository --limit 50 +``` + +Optional notifications: + +```bash +gh api 'notifications?participating=true&per_page=50' \ + --jq '.[] | select(.repository.owner.login=="heavy-duty") | {repo: .repository.full_name, title: .subject.title, reason, type: .subject.type}' +``` + +### 2. For each match — read everything first + +**Never comment without full context.** Derive `OWNER/REPO` from `repository.nameWithOwner` (or `heavy-duty/`). + +For **PRs** (`N` = number, `REPO` = full name): + +```bash +gh pr view N --repo REPO --json title,body,author,assignees,reviewRequests,reviews,comments,commits,files,baseRefName,headRefName,headRefOid,isDraft,state,url,updatedAt +gh api repos/REPO/pulls/N/comments --paginate +gh api repos/REPO/issues/N/comments --paginate +gh pr diff N --repo REPO +gh pr checks N --repo REPO +``` + +For **Issues**: + +```bash +gh issue view N --repo REPO --json title,body,author,assignees,comments,labels,state,url +# paginate comments if needed +``` + +**Always scan prior comments/reviews by `grok-bot-andresmgsl`** so you never repeat yourself. + +### 3. Decide whether to speak + +Comment / review only when **at least one** is true: + +- First time this bot is assigned / requested on this item +- New commits since last review (PR `headRefOid` changed vs state) +- New human comments that need a response (especially replies to our blockers) +- CI status changed in a way that affects the review +- Explicit re-request of review + +If nothing material changed → **silent poll**. Log it. Do not spam. + +### 4. Feedback style (GitHub collaboration best practices) + +**Be explicit about the verdict at the top:** + +| Kind | When | +|------|------| +| `**Verdict: Approve** — I agree with this as-is.` | Fully agree; no remaining blockers | +| `**Verdict: Comment** — overall direction is good; feedback below.` | Non-blocking notes only | +| `**Verdict: Request changes** — blockers listed below.` | Must fix before approval | + +For issues: + +- `**Status: Aligned**` +- `**Status: Feedback**` +- `**Status: Need info**` + +Rules: + +- **Specific**: cite file paths, line ranges, commit SHAs, behaviors +- **Non-redundant**: do not restate the author; do not re-open resolved threads without new evidence +- **Actionable**: each point implies a concrete next step (or mark `optional / nit`) +- **Separate blockers from nits**: `### Blockers` and `### Nits / optional` +- **Tone**: professional, collaborative, concise — no filler +- **Draft PRs**: lighter touch unless asked for a full review +- Prefer **`gh pr review`** for code over scattered conversation comments +- Prefer **threaded replies** when continuing an existing review discussion +- When peers already said the same blocker, **agree briefly and add only net-new signal** + +### 5. Posting + +```bash +# PR review +gh pr review N --repo REPO --approve --body "..." +gh pr review N --repo REPO --request-changes --body "..." +gh pr review N --repo REPO --comment --body "..." + +# Issue / conversation comment +gh issue comment N --repo REPO --body "..." +``` + +**Never** force-push, merge, close, or re-assign unless the operator explicitly asks in-session. + +### 6. Update state + +Write `state.json`: + +- `scope`: `"org:heavy-duty"` +- `last_poll_at`: ISO UTC +- `last_poll_summary`: one line +- `items` keyed as `owner/repo#pr:N` or `owner/repo#issue:N`, each with: + - `url`, `title`, `role` (`assignee` | `reviewer` | both) + - `last_seen_updated_at`, `last_head_sha` (PRs) + - `last_action` (`reviewed` | `commented` | `silent` | `skipped`) + - `last_action_at`, `review_event` if any, `notes` + +Append to `logs/poll.log`: + +```text +[ISO_UTC] summary... +``` + +### 7. Report back + +Briefly list: scanned counts, what was posted (with links), what was intentionally silent and why. + +If zero assignments: update state, log `0 actionable`, stop. Do not invent work. + +## Review loop (“until you agree”) + +```text +discover → read all context → decide + │ + ├─ blockers remain → Request changes (specific, actionable) + ├─ only nits → Comment (optional) or Approve if nits truly optional + └─ fully agree → Approve + │ + └─ later: new commits / re-request / author reply + → re-read ALL comments + new diff vs last_head_sha + → Approve or Request changes again (no zombie opinions) +``` + +## Identity + +- Bot: `grok-bot-andresmgsl` +- Org: `heavy-duty` +- Role: assigned reviewer / assignee agent — **not** an unsolicited drive-by bot diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec58fc5 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# heavy-duty-watcher + +**Org-wide GitHub review agent** for the [heavy-duty](https://github.com/heavy-duty) organization. + +Runs as bot **`grok-bot-andresmgsl`**. Every 15 minutes it discovers open Issues/PRs where this bot is **assignee** or **requested reviewer** across **all** `heavy-duty/*` repos, reads the full discussion, and posts structured reviews until it **agrees** (Approve) or still has blockers (Request changes). + +> Not a drive-by bot. No unsolicited comments. No merge/close/reassign unless an operator asks. + +--- + +## Features + +- **Org-wide scope** — one watcher for every repo under `heavy-duty` +- **Strict collaboration rules** — clear Verdict/Status, specific, non-redundant, blockers vs nits +- **Stateful de-dupe** — tracks per-item head SHA and last action (`owner/repo#pr:N`) +- **Dual durability** — Grok durable schedulers **and** local tmux loops +- **Reboot recovery** — `scripts/restore.sh` rebuilds tmux + resumes the agent +- **Hourly health watchdog** — verifies poll loop, gh auth, freshness; self-heals + +--- + +## Quick start + +```bash +git clone https://github.com/grok-bot-andresmgsl/heavy-duty-watcher.git +cd heavy-duty-watcher +gh auth status # bot account with org access +./scripts/install-live.sh +./scripts/restore.sh # after reboot: always this +``` + +Arm durable schedulers from a live Grok session using prompts in [`docs/SCHEDULER_PROMPTS.md`](docs/SCHEDULER_PROMPTS.md). + +--- + +## Repository layout + +```text +heavy-duty-watcher/ +├── README.md ← you are here +├── POLL_INSTRUCTIONS.md ← authoritative review rules (read every cycle) +├── config/ +│ ├── state.template.json +│ └── watcher.env.example +├── scripts/ +│ ├── discover.sh ← org-wide actionable list +│ ├── poll-once.sh ← one headless poll (Grok) +│ ├── poll-loop.sh ← every 15m +│ ├── health-check.sh +│ ├── health-loop.sh ← every 1h + self-heal +│ ├── restore.sh ← reboot recovery (tmux) +│ └── install-live.sh ← deploy under ~/heavy-duty-watcher +├── docs/ +│ ├── ARCHITECTURE.md +│ ├── OPERATIONS.md +│ ├── SCHEDULER_PROMPTS.md +│ └── REVIEW_PLAYBOOK.md +└── logs/ ← runtime (gitignored) +``` + +--- + +## How a poll works + +```text +discover (org search) + → for each match + read ALL comments + reviews + diff + checks + compare head SHA / prior bot comments (state.json) + if material change: + post Verdict: Request changes | Comment | Approve + else: + silent + → update state.json + logs/poll.log +``` + +Full rules: [`POLL_INSTRUCTIONS.md`](POLL_INSTRUCTIONS.md). + +--- + +## Documentation map + +| Doc | Audience | +|-----|----------| +| [POLL_INSTRUCTIONS.md](POLL_INSTRUCTIONS.md) | Agent — must follow every cycle | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design / durability model | +| [docs/OPERATIONS.md](docs/OPERATIONS.md) | Humans operating the watcher | +| [docs/SCHEDULER_PROMPTS.md](docs/SCHEDULER_PROMPTS.md) | Copy-paste durable task prompts | +| [docs/REVIEW_PLAYBOOK.md](docs/REVIEW_PLAYBOOK.md) | Review style examples | + +--- + +## After reboot + +```bash +~/heavy-duty-watcher/scripts/restore.sh +``` + +Creates/attaches tmux session `heavy-duty-watcher` with: + +| Window | Role | +|--------|------| +| `grok` | Interactive agent (resume session when possible) | +| `poll-loop` | Headless 15m polls | +| `health-loop` | Hourly health + self-heal | +| `tools` | Manual shell | + +--- + +## Safety + +- Only comments when **assigned** or **review-requested** +- Never merges, closes, force-pushes, or reassigns by default +- Secrets are **not** stored in this repo — use `gh auth` on the host + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/config/state.template.json b/config/state.template.json new file mode 100644 index 0000000..87fc16e --- /dev/null +++ b/config/state.template.json @@ -0,0 +1,22 @@ +{ + "scope": "org:heavy-duty", + "org": "heavy-duty", + "bot_login": "grok-bot-andresmgsl", + "session_id": null, + "poll_interval_minutes": 15, + "health_interval_minutes": 60, + "created_at": null, + "last_poll_at": null, + "last_poll_summary": null, + "items": {}, + "notes": "items keyed as 'owner/repo#pr:N' or 'owner/repo#issue:N'. Track last_seen_updated_at, last_head_sha (PRs), last_action, review_event.", + "scheduler": {}, + "restore": { + "command": "./scripts/restore.sh", + "tmux_session": "heavy-duty-watcher" + }, + "health": { + "last_check_at": null, + "status": null + } +} diff --git a/config/watcher.env.example b/config/watcher.env.example new file mode 100644 index 0000000..3043d72 --- /dev/null +++ b/config/watcher.env.example @@ -0,0 +1,8 @@ +# Copy to watcher.env (gitignored) for local overrides. +WATCHER_DIR="${WATCHER_DIR:-$HOME/heavy-duty-watcher}" +ORG="${ORG:-heavy-duty}" +BOT_LOGIN="${BOT_LOGIN:-grok-bot-andresmgsl}" +POLL_INTERVAL_SEC="${POLL_INTERVAL_SEC:-900}" +HEALTH_INTERVAL_SEC="${HEALTH_INTERVAL_SEC:-3600}" +STALE_POLL_MINUTES="${STALE_POLL_MINUTES:-45}" +TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a278ecd --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,78 @@ +# Architecture + +## What this is + +A **durable, org-wide GitHub review agent** for [heavy-duty](https://github.com/heavy-duty). + +It polls GitHub for Issues/PRs where bot **`grok-bot-andresmgsl`** is: + +- requested as **reviewer**, or +- **assignee** + +…across **every repository** in the organization. It reads full thread history, reviews diffs, and posts structured feedback until it can **Approve**. + +## Components + +```text +┌─────────────────────────────────────────────────────────────┐ +│ Grok durable scheduler (15m) ──► poll cycle (interactive) │ +│ Grok durable scheduler (1h) ──► health watchdog │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ tmux session: heavy-duty-watcher │ +│ ├─ poll-loop (scripts/poll-loop.sh → poll-once.sh) │ +│ ├─ health-loop (scripts/health-loop.sh → health-check.sh) │ +│ ├─ tools (manual gh / logs) │ +│ └─ grok (optional interactive resume) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + GitHub org:heavy-duty + state.json + logs/ +``` + +### Why two poll paths? + +| Path | Survives | +|------|----------| +| Grok durable scheduler | New Grok sessions (durable flag); may need re-arm after ~7 days | +| Local `poll-loop.sh` in tmux | Box uptime; independent of Grok UI | + +After reboot: run `scripts/restore.sh`. + +## State model + +`state.json` is the single source of truth for de-duplication: + +```json +{ + "scope": "org:heavy-duty", + "items": { + "heavy-duty/box#pr:79": { + "last_head_sha": "abc…", + "last_action": "reviewed", + "review_event": "REQUEST_CHANGES" + } + } +} +``` + +Keys are always `owner/repo#pr:N` or `owner/repo#issue:N` so multiple repos never collide. + +## Review policy (summary) + +Full rules live in [`../POLL_INSTRUCTIONS.md`](../POLL_INSTRUCTIONS.md). + +- Act only when assignee or review-requested +- Read **all** comments before speaking +- Clear **Verdict** / **Status** line first +- **Request changes** until agreement; **Approve** when fully satisfied +- Silent when nothing material changed + +## Security / safety + +- No merge / close / force-push / reassignment unless operator asks +- Uses the bot’s GitHub token via `gh` / MCP +- Does not store secrets in the repo (state is operational metadata only) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..af0de97 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,69 @@ +# Operations runbook + +## First-time setup + +```bash +# 1. Clone +git clone https://github.com/grok-bot-andresmgsl/heavy-duty-watcher.git +cd heavy-duty-watcher + +# 2. Auth +gh auth status # must be grok-bot-andresmgsl (or your reviewer bot) + +# 3. Install live + start tmux loops +./scripts/install-live.sh + +# 4. In a Grok session: arm durable schedulers (15m poll + 1h health) +# Or restore interactive session: +./scripts/restore.sh +``` + +## After PC reboot / box restart + +```bash +gh auth status +~/heavy-duty-watcher/scripts/restore.sh +# or: +~/heavy-duty-watcher/scripts/restore.sh --status +``` + +## Day-to-day + +```bash +# What is waiting for us right now? +./scripts/discover.sh + +# One-shot health +./scripts/health-check.sh + +# Follow activity +tail -f logs/poll.log logs/health.log + +# Attach tmux +tmux attach -t heavy-duty-watcher +``` + +## Schedulers (Grok) + +When the agent is live, it should keep two durable recurring tasks: + +1. **Poll every 15 minutes** — org-wide discovery + review +2. **Health every 1 hour** — `health-check.sh` + self-heal poll-loop + +If either disappears, re-create from the prompts in `docs/SCHEDULER_PROMPTS.md`. + +## Migration from `~/rig-watcher` + +The original single-repo install lived at `~/rig-watcher`. +`install-live.sh` can symlink `~/rig-watcher` → `~/heavy-duty-watcher` for compatibility. +Item keys changed from `pr:N` to `heavy-duty/rig#pr:N`. + +## Troubleshooting + +| Symptom | Check | +|---------|--------| +| No reviews happening | `./scripts/discover.sh` empty? Not requested. | +| Stale last_poll | `health-check.sh` WARN; is poll-loop alive? | +| gh 401 | `gh auth login` | +| Duplicate spam | Inspect `state.json` `last_head_sha` / `last_action` | +| Scheduler missing | Re-arm via Grok; see `docs/SCHEDULER_PROMPTS.md` | diff --git a/docs/REVIEW_PLAYBOOK.md b/docs/REVIEW_PLAYBOOK.md new file mode 100644 index 0000000..95daeb8 --- /dev/null +++ b/docs/REVIEW_PLAYBOOK.md @@ -0,0 +1,51 @@ +# Review playbook + +## When we act + +| Signal | Action | +|--------|--------| +| First review request | Full review | +| Head SHA changed after our review | Re-review delta + full thread | +| Author replies to our blockers | Re-read thread; Approve or keep REQUEST_CHANGES | +| Explicit re-request | Full re-review | +| Nothing material | Silent | + +## Verdict ladder + +1. **Request changes** — correctness, security, lockout risk, broken contracts, missing tests for the claimed fix +2. **Comment** — design notes, optional improvements, clarifying questions +3. **Approve** — you fully agree; no remaining blockers + +## Comment shape + +```markdown +**Verdict: Request changes** + +### Blockers +1. `path:lines` — what is wrong, why it matters, how to fix + +### Nits / optional +- … + +Happy to re-review once … +``` + +```markdown +**Verdict: Approve** — I agree with this as-is. + +### What closed prior feedback +- … + +No new blockers. +``` + +## Peer reviews + +If another bot already filed the same blocker: + +- Agree in one sentence +- Add only **net-new** signal (missed edge case, test gap, wording overclaim) + +## Drafts + +Unless explicitly asked for a deep review: high-level risks and understanding only; avoid nitpicking unfinished work. diff --git a/docs/SCHEDULER_PROMPTS.md b/docs/SCHEDULER_PROMPTS.md new file mode 100644 index 0000000..7864087 --- /dev/null +++ b/docs/SCHEDULER_PROMPTS.md @@ -0,0 +1,46 @@ +# Durable scheduler prompts + +Paste these when creating Grok durable schedulers (`scheduler_create`). + +## Poll — every 15 minutes + +**Interval:** `15m` · **Recurring:** true · **Durable:** true + +```text +You are the durable poll runner for the heavy-duty org GitHub watcher. + +Execute ONE full poll cycle now: + +1. Read /home/grok/heavy-duty-watcher/POLL_INSTRUCTIONS.md and /home/grok/heavy-duty-watcher/state.json. +2. Authenticated as grok-bot-andresmgsl. Scope: ALL repositories under org heavy-duty. +3. Discover open Issues/PRs where this bot is assignee OR requested reviewer: + - gh search prs --owner heavy-duty --review-requested=@me --state open --json number,title,url,updatedAt,author,assignees,repository --limit 50 + - gh search prs --owner heavy-duty --assignee=@me --state open --json number,title,url,updatedAt,author,assignees,repository --limit 50 + - gh search issues --owner heavy-duty --assignee=@me --state open --json number,title,url,updatedAt,author,assignees,repository --limit 50 +4. For each match: read ALL conversation comments, PR reviews, and inline review comments BEFORE posting. Check prior comments by grok-bot-andresmgsl so you never repeat yourself. Use --repo owner/name from search results. +5. Comment/review ONLY if first assignment, new commits (head SHA change), new human comments needing reply, material CI change, or explicit re-request. Otherwise stay silent. +6. Feedback style: clear Verdict (Approve / Comment / Request changes) or Status (Aligned / Feedback / Need info). Specific, non-redundant, actionable. Separate blockers vs nits. Prefer gh pr review. Keep reviewing across updates until you fully agree (then Approve). +7. Do NOT merge, close, reassign, or drive-by comment on unassigned items. +8. Update state.json (keys owner/repo#pr:N or owner/repo#issue:N) and append one line to logs/poll.log. +9. Short report of what was scanned / posted / intentionally silent. + +If zero assignments: update state, log "0 actionable", and exit quietly. +``` + +## Health — every 1 hour + +**Interval:** `1h` · **Recurring:** true · **Durable:** true + +```text +You are the hourly HEALTH WATCHDOG for the heavy-duty org GitHub watcher. + +1. Run: bash /home/grok/heavy-duty-watcher/scripts/health-check.sh +2. Read logs/health-latest.json, tail logs/health.log and logs/poll.log +3. Verify tmux session (heavy-duty-watcher or legacy rig-watcher), poll-loop process, non-stale last_poll_at, gh auth as grok-bot-andresmgsl +4. If poll-loop dead: restart in tmux without attaching +5. If 15m poll scheduler missing: re-arm using docs/SCHEDULER_PROMPTS.md +6. Light discovery (no comments unless assigned): scripts/discover.sh or gh search --owner heavy-duty … +7. Report one short line: HEALTH ok|degraded|down — details + +Do not merge/close/reassign. Do not drive-by comment. +``` diff --git a/scripts/discover.sh b/scripts/discover.sh new file mode 100755 index 0000000..8534f85 --- /dev/null +++ b/scripts/discover.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Org-wide discovery of actionable PRs/issues for the bot. +set -euo pipefail +ORG="${ORG:-heavy-duty}" +LIMIT="${LIMIT:-50}" + +echo "=== review-requested PRs (org:${ORG}) ===" +gh search prs --owner "${ORG}" --review-requested=@me --state open \ + --json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}" + +echo "=== assignee PRs (org:${ORG}) ===" +gh search prs --owner "${ORG}" --assignee=@me --state open \ + --json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}" + +echo "=== assignee issues (org:${ORG}) ===" +gh search issues --owner "${ORG}" --assignee=@me --state open \ + --json number,title,url,updatedAt,author,assignees,repository --limit "${LIMIT}" diff --git a/scripts/health-check.sh b/scripts/health-check.sh new file mode 100755 index 0000000..a9027d0 --- /dev/null +++ b/scripts/health-check.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Health check for the heavy-duty org watcher. Exit 0=ok, 1=degraded, 2=down. +set -euo pipefail + +WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_FILE="${WATCHER_DIR}/state.json" +LOG_DIR="${WATCHER_DIR}/logs" +HEALTH_LOG="${LOG_DIR}/health.log" +TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}" +# Accept legacy session name during migration +LEGACY_TMUX="${LEGACY_TMUX:-rig-watcher}" +STALE_POLL_MINUTES="${STALE_POLL_MINUTES:-45}" +mkdir -p "${LOG_DIR}" + +NOW_EPOCH="$(date -u +%s)" +NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +STATUS="ok" +ISSUES=() + +ok() { ISSUES+=("OK: $1"); } +warn() { STATUS="degraded"; ISSUES+=("WARN: $1"); } +fail() { STATUS="down"; ISSUES+=("FAIL: $1"); } + +if gh api user --jq .login >/dev/null 2>&1; then + LOGIN="$(gh api user --jq .login 2>/dev/null || echo unknown)" + ok "gh authenticated as ${LOGIN}" +else + fail "gh not authenticated" +fi + +ACTIVE_TMUX="" +if tmux has-session -t "${TMUX_SESSION}" 2>/dev/null; then + ACTIVE_TMUX="${TMUX_SESSION}" +elif tmux has-session -t "${LEGACY_TMUX}" 2>/dev/null; then + ACTIVE_TMUX="${LEGACY_TMUX}" + warn "using legacy tmux session ${LEGACY_TMUX} (prefer ${TMUX_SESSION})" +fi + +if [[ -n "${ACTIVE_TMUX}" ]]; then + ok "tmux session ${ACTIVE_TMUX} exists" + if tmux list-windows -t "${ACTIVE_TMUX}" -F '#{window_name}' 2>/dev/null | grep -Eqx 'poll-loop'; then + ok "tmux window poll-loop present" + else + warn "tmux window poll-loop missing" + fi +else + fail "tmux session missing (run scripts/restore.sh)" +fi + +if ps -eo args | awk '/poll-loop\.sh/ && !/awk/ {found=1} END{exit !found}'; then + ok "poll-loop.sh process running" +else + fail "poll-loop.sh process not running" +fi + +if [[ -f "${STATE_FILE}" ]]; then + LAST_POLL="$(jq -r '.last_poll_at // empty' "${STATE_FILE}" 2>/dev/null || true)" + SCOPE="$(jq -r '.scope // .repo // "unknown"' "${STATE_FILE}" 2>/dev/null || echo unknown)" + ok "scope: ${SCOPE}" + if [[ -n "${LAST_POLL}" ]]; then + if LAST_EPOCH="$(date -u -d "${LAST_POLL}" +%s 2>/dev/null)"; then + AGE_MIN=$(( (NOW_EPOCH - LAST_EPOCH) / 60 )) + if (( AGE_MIN > STALE_POLL_MINUTES )); then + warn "last_poll_at is ${AGE_MIN}m old (${LAST_POLL})" + else + ok "last_poll_at fresh (${AGE_MIN}m ago: ${LAST_POLL})" + fi + else + ok "last_poll_at present (${LAST_POLL})" + fi + else + warn "state.json has no last_poll_at" + fi + SUMMARY="$(jq -r '.last_poll_summary // "n/a"' "${STATE_FILE}" 2>/dev/null || echo n/a)" + ok "last_poll_summary: ${SUMMARY}" +else + fail "state.json missing" +fi + +[[ -f "${WATCHER_DIR}/POLL_INSTRUCTIONS.md" ]] && ok "POLL_INSTRUCTIONS.md present" || fail "POLL_INSTRUCTIONS.md missing" + +RESULT_JSON="${LOG_DIR}/health-latest.json" +{ + echo "{" + echo " \"checked_at\": \"${NOW_ISO}\"," + echo " \"status\": \"${STATUS}\"," + echo " \"issues\": [" + first=1 + for line in "${ISSUES[@]}"; do + esc="${line//\"/\\\"}" + if (( first )); then first=0; else echo ","; fi + printf ' "%s"' "${esc}" + done + echo + echo " ]" + echo "}" +} > "${RESULT_JSON}" + +echo "[${NOW_ISO}] status=${STATUS} | $(IFS=' ; '; echo "${ISSUES[*]}")" >> "${HEALTH_LOG}" + +if command -v jq >/dev/null 2>&1 && [[ -f "${STATE_FILE}" ]]; then + tmp="$(mktemp)" + jq --arg t "${NOW_ISO}" --arg s "${STATUS}" \ + '.health = {last_check_at: $t, status: $s}' "${STATE_FILE}" > "${tmp}" \ + && mv "${tmp}" "${STATE_FILE}" +fi + +echo "status=${STATUS}" +printf '%s\n' "${ISSUES[@]}" +case "${STATUS}" in + ok) exit 0 ;; + degraded) exit 1 ;; + *) exit 2 ;; +esac diff --git a/scripts/health-loop.sh b/scripts/health-loop.sh new file mode 100755 index 0000000..b721e77 --- /dev/null +++ b/scripts/health-loop.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Hourly health check + self-heal for poll-loop. +set -euo pipefail + +WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +LOG_DIR="${WATCHER_DIR}/logs" +INTERVAL_SEC="${HEALTH_INTERVAL_SEC:-3600}" +TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}" +LEGACY_TMUX="${LEGACY_TMUX:-rig-watcher}" +mkdir -p "${LOG_DIR}" + +echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] health-loop started interval=${INTERVAL_SEC}s" \ + >> "${LOG_DIR}/health.log" + +"${WATCHER_DIR}/scripts/health-check.sh" || true + +session_name() { + if tmux has-session -t "${TMUX_SESSION}" 2>/dev/null; then + echo "${TMUX_SESSION}" + elif tmux has-session -t "${LEGACY_TMUX}" 2>/dev/null; then + echo "${LEGACY_TMUX}" + else + echo "" + fi +} + +while true; do + sleep "${INTERVAL_SEC}" + set +e + "${WATCHER_DIR}/scripts/health-check.sh" + rc=$? + set -e + + if ! ps -eo args | awk '/poll-loop\.sh/ && !/awk/ {found=1} END{exit !found}'; then + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] health-loop: restarting poll-loop" \ + >> "${LOG_DIR}/health.log" + sess="$(session_name)" + if [[ -n "${sess}" ]]; then + tmux list-windows -t "${sess}" -F '#{window_index} #{window_name}' \ + | awk '$2=="poll-loop"{print $1}' \ + | while read -r idx; do tmux kill-window -t "${sess}:${idx}" 2>/dev/null || true; done + tmux new-window -t "${sess}" -n poll-loop -c "${WATCHER_DIR}" \ + "bash ${WATCHER_DIR}/scripts/poll-loop.sh" + else + tmux new-session -d -s "${TMUX_SESSION}" -n poll-loop -c "${WATCHER_DIR}" \ + "bash ${WATCHER_DIR}/scripts/poll-loop.sh" + tmux new-window -t "${TMUX_SESSION}" -n health-loop -c "${WATCHER_DIR}" \ + "bash ${WATCHER_DIR}/scripts/health-loop.sh" + fi + fi + + if (( rc != 0 )); then + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] health-loop: status non-ok rc=${rc}" \ + >> "${LOG_DIR}/health.log" + fi +done diff --git a/scripts/install-live.sh b/scripts/install-live.sh new file mode 100755 index 0000000..748d7ac --- /dev/null +++ b/scripts/install-live.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Install/refresh the live watcher under $HOME/heavy-duty-watcher and start tmux loops. +set -euo pipefail + +SRC="$(cd "$(dirname "$0")/.." && pwd)" +DEST="${DEST:-$HOME/heavy-duty-watcher}" +TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}" + +echo "Installing watcher: ${SRC} -> ${DEST}" +mkdir -p "${DEST}" +# Copy tree but preserve local state/logs if DEST already has them +rsync -a --exclude '.git' --exclude 'state.json' --exclude 'logs/' --exclude '.poll-prompt.txt' \ + "${SRC}/" "${DEST}/" + +mkdir -p "${DEST}/logs" +if [[ ! -f "${DEST}/state.json" ]]; then + NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + jq --arg t "$NOW" '.created_at=$t | .last_poll_at=null' \ + "${DEST}/config/state.template.json" > "${DEST}/state.json" + echo "seeded state.json" +fi + +chmod +x "${DEST}/scripts/"*.sh + +# Compatibility symlink for old path +if [[ ! -e "$HOME/rig-watcher" ]]; then + ln -s "${DEST}" "$HOME/rig-watcher" + echo "linked ~/rig-watcher -> ${DEST}" +fi + +# Ensure tmux loops +if tmux has-session -t "${TMUX_SESSION}" 2>/dev/null; then + echo "tmux ${TMUX_SESSION} already exists" +else + tmux new-session -d -s "${TMUX_SESSION}" -n poll-loop -c "${DEST}" \ + "bash ${DEST}/scripts/poll-loop.sh" + tmux new-window -t "${TMUX_SESSION}" -n health-loop -c "${DEST}" \ + "bash ${DEST}/scripts/health-loop.sh" + tmux new-window -t "${TMUX_SESSION}" -n tools -c "${DEST}" \ + "echo 'heavy-duty-watcher tools'; exec bash" + echo "started tmux session ${TMUX_SESSION}" +fi + +bash "${DEST}/scripts/health-check.sh" || true +echo "Live install ready. Restore after reboot: ${DEST}/scripts/restore.sh" diff --git a/scripts/poll-loop.sh b/scripts/poll-loop.sh new file mode 100755 index 0000000..3ebcf93 --- /dev/null +++ b/scripts/poll-loop.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Long-running poll loop (default 15 minutes) for tmux. +set -euo pipefail + +WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +LOG_DIR="${WATCHER_DIR}/logs" +INTERVAL_SEC="${POLL_INTERVAL_SEC:-900}" +mkdir -p "${LOG_DIR}" + +echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] poll-loop started interval=${INTERVAL_SEC}s scope=org:heavy-duty (sleep-first)" \ + >> "${LOG_DIR}/poll.log" + +if [[ "${POLL_NOW:-0}" == "1" ]]; then + "${WATCHER_DIR}/scripts/poll-once.sh" || true +fi + +while true; do + sleep "${INTERVAL_SEC}" + if ps -eo args | awk '/heavy-duty-watcher\/scripts\/poll-once\.sh/ && !/awk/ {found=1} END{exit !found}'; then + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] poll-loop: previous poll still running, skipping" >> "${LOG_DIR}/poll.log" + else + "${WATCHER_DIR}/scripts/poll-once.sh" || true + fi +done diff --git a/scripts/poll-once.sh b/scripts/poll-once.sh new file mode 100755 index 0000000..9f36376 --- /dev/null +++ b/scripts/poll-once.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Single headless poll via Grok for the heavy-duty org watcher. +set -euo pipefail + +WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +LOG_DIR="${WATCHER_DIR}/logs" +mkdir -p "${LOG_DIR}" +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +PROMPT_FILE="${WATCHER_DIR}/.poll-prompt.txt" + +cat > "${PROMPT_FILE}" <> "${LOG_DIR}/poll.log" + +if grok --always-approve --cwd "${HOME}" --max-turns 80 \ + --single "$(cat "${PROMPT_FILE}")" \ + >> "${LOG_DIR}/poll-headless.log" 2>&1; then + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] poll-once.sh finished ok" >> "${LOG_DIR}/poll.log" +else + rc=$? + echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] poll-once.sh failed rc=${rc}" >> "${LOG_DIR}/poll.log" + exit "${rc}" +fi diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..747cb08 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Restore the heavy-duty org watcher after reboot / session loss. +# Usage: +# ./scripts/restore.sh # create/attach tmux + resume Grok +# ./scripts/restore.sh --attach # attach only +# ./scripts/restore.sh --status # health snapshot +set -euo pipefail + +WATCHER_DIR="${WATCHER_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +STATE_FILE="${WATCHER_DIR}/state.json" +TMUX_SESSION="${TMUX_SESSION:-heavy-duty-watcher}" +LOG_DIR="${WATCHER_DIR}/logs" +mkdir -p "${LOG_DIR}" + +session_id() { + if [[ -f "${STATE_FILE}" ]] && command -v jq >/dev/null 2>&1; then + local id + id="$(jq -r '.session_id // empty' "${STATE_FILE}" 2>/dev/null || true)" + if [[ -n "${id}" && "${id}" != "null" ]]; then + echo "${id}" + return + fi + fi + echo "" +} + +status() { + echo "=== heavy-duty-watcher status ===" + echo "dir: ${WATCHER_DIR}" + if [[ -f "${STATE_FILE}" ]]; then + jq '{scope, org, bot_login, session_id, last_poll_at, last_poll_summary, health, items: (.items|keys)}' \ + "${STATE_FILE}" 2>/dev/null || cat "${STATE_FILE}" + else + echo "(missing state.json)" + fi + echo + tmux ls 2>/dev/null || echo "(no tmux sessions)" + echo + echo "gh user: $(gh api user --jq .login 2>/dev/null || echo 'not authenticated')" + echo "session_id: $(session_id || true)" + [[ -f "${LOG_DIR}/poll.log" ]] && { echo; echo "last poll log:"; tail -n 5 "${LOG_DIR}/poll.log"; } + [[ -f "${LOG_DIR}/health.log" ]] && { echo; echo "last health log:"; tail -n 5 "${LOG_DIR}/health.log"; } + bash "${WATCHER_DIR}/scripts/health-check.sh" || true +} + +attach_only() { + if tmux has-session -t "${TMUX_SESSION}" 2>/dev/null; then + exec tmux attach -t "${TMUX_SESSION}" + fi + echo "tmux session '${TMUX_SESSION}' not running. Run without --attach to start." + exit 1 +} + +start_or_attach() { + local sid resume_prompt + sid="$(session_id)" + resume_prompt="$(cat </dev/null; then + if ! tmux list-windows -t "${TMUX_SESSION}" -F '#{window_name}' 2>/dev/null | grep -qx 'poll-loop'; then + tmux new-window -t "${TMUX_SESSION}" -n poll-loop -c "${WATCHER_DIR}" \ + "bash ${WATCHER_DIR}/scripts/poll-loop.sh" + fi + if ! tmux list-windows -t "${TMUX_SESSION}" -F '#{window_name}' 2>/dev/null | grep -qx 'health-loop'; then + tmux new-window -t "${TMUX_SESSION}" -n health-loop -c "${WATCHER_DIR}" \ + "bash ${WATCHER_DIR}/scripts/health-loop.sh" + fi + echo "tmux session '${TMUX_SESSION}' exists — attaching." + exec tmux attach -t "${TMUX_SESSION}" + fi + + echo "Creating tmux session '${TMUX_SESSION}'..." + local grok_cmd + if [[ -n "${sid}" ]]; then + grok_cmd="grok -r ${sid} --cwd ${HOME} $(printf %q "${resume_prompt}") || grok -c --cwd ${HOME} $(printf %q "${resume_prompt}") || grok --cwd ${HOME} $(printf %q "${resume_prompt}")" + else + grok_cmd="grok --cwd ${HOME} $(printf %q "${resume_prompt}")" + fi + + tmux new-session -d -s "${TMUX_SESSION}" -n grok -c "${HOME}" \ + "${grok_cmd}; echo; echo '[heavy-duty-watcher] Grok exited.'; exec bash" + + tmux new-window -t "${TMUX_SESSION}" -n poll-loop -c "${WATCHER_DIR}" \ + "bash ${WATCHER_DIR}/scripts/poll-loop.sh" + + tmux new-window -t "${TMUX_SESSION}" -n health-loop -c "${WATCHER_DIR}" \ + "bash ${WATCHER_DIR}/scripts/health-loop.sh" + + tmux new-window -t "${TMUX_SESSION}" -n tools -c "${WATCHER_DIR}" \ + "echo 'tools: ./scripts/discover.sh | ./scripts/health-check.sh | tail -f logs/poll.log'; exec bash" + + tmux select-window -t "${TMUX_SESSION}:grok" + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore.sh started ${TMUX_SESSION}" >> "${LOG_DIR}/poll.log" + exec tmux attach -t "${TMUX_SESSION}" +} + +case "${1:-}" in + --status|-s) status ;; + --attach|-a) attach_only ;; + --help|-h) sed -n '2,7p' "$0" ;; + *) start_or_attach ;; +esac