diff --git a/.cursor/bin/riven-loop-tick.ts b/.cursor/bin/riven-loop-tick.ts deleted file mode 100644 index 50a9886f00..0000000000 --- a/.cursor/bin/riven-loop-tick.ts +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env bun -// riven-loop-tick.ts — host-level launchd heartbeat runner for Riven (Cursor/Grok). -// Parity with .claude/bin/claude-loop-tick.ts (Otto) and .codex/bin/codex-loop-tick.ts (Vera). -// -// Runs every 60s via macOS launchd. Per-minute heartbeat checks git state. -// Every ZETA_RIVEN_LOOP_AGENT_INTERVAL_SECONDS (default 900 = 15min) runs a real -// Cursor agent read-only gate via `agent` CLI. - -import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; - -const home = process.env.HOME ?? "/Users/acehack"; -const worktree = process.env.ZETA_RIVEN_LOOP_WORKTREE ?? "/tmp/zeta-riven-loop-2"; -const stateDir = process.env.ZETA_RIVEN_LOOP_STATE_DIR ?? join(home, "Library/Application Support/ZetaRivenLoop"); -const logDir = process.env.ZETA_RIVEN_LOOP_LOG_DIR ?? join(home, "Library/Logs/zeta-riven-loop"); -const lockDir = join(stateDir, "lock"); -const runId = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); -const lockTtlMs = Number(process.env.ZETA_RIVEN_LOOP_LOCK_TTL_SECONDS ?? "120") * 1000; -const fetchTimeoutMs = Number(process.env.ZETA_RIVEN_LOOP_FETCH_TIMEOUT_SECONDS ?? "45") * 1000; -const runAgent = process.env.ZETA_RIVEN_LOOP_RUN_AGENT === "1"; -const agentIntervalMs = Number(process.env.ZETA_RIVEN_LOOP_AGENT_INTERVAL_SECONDS ?? "900") * 1000; -const agentTimeoutMs = Number(process.env.ZETA_RIVEN_LOOP_AGENT_TIMEOUT_SECONDS ?? "300") * 1000; -const dryRun = process.env.ZETA_RIVEN_LOOP_DRY_RUN === "1"; -const agentStateFile = join(stateDir, "last-agent-run.json"); -const agentBinCandidates = (process.env.ZETA_RIVEN_LOOP_AGENT_BIN ?? "agent,cursor-agent").split(",").map(s => s.trim()).filter(s => s.length > 0); - -mkdirSync(stateDir, { recursive: true }); -mkdirSync(logDir, { recursive: true }); - -function nowIso(): string { - return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); -} - -function log(message: string): void { - appendFileSync(join(logDir, "runner.log"), `${nowIso()} ${message} -`); -} - -function run(command: string, args: string[], timeoutMs: number): { status: number; stdout: string; stderr: string } { - const result = spawnSync(command, args, { - cwd: worktree, - encoding: "utf8", - env: { - ...process.env, - PATH: `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${join(home, ".local/bin")}`, - }, - timeout: timeoutMs, - maxBuffer: 20 * 1024 * 1024, - }); - return { - status: result.status ?? (result.signal ? 124 : 1), - stdout: result.stdout ?? "", - stderr: result.stderr ?? String(result.error ?? ""), - }; -} - -function resolveAgentBin(): string | null { - const pathDirs = `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${join(home, ".local/bin")}`.split(":"); - for (const bin of agentBinCandidates) { - const probe = spawnSync("/usr/bin/which", [bin], { - encoding: "utf8", - env: { ...process.env, PATH: pathDirs.join(":") }, - timeout: 5000, - }); - if (probe.status === 0 && (probe.stdout ?? "").trim().length > 0) { - return bin; - } - } - return null; -} - -function lines(text: string): string[] { - return text.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0); -} - -function acquireLock(): boolean { - try { - mkdirSync(lockDir, { recursive: false }); - writeFileSync(join(lockDir, "metadata"), `pid=${process.pid} -run_id=${runId} -acquired_at=${nowIso()} -`); - return true; - } catch { - try { - const meta = readFileSync(join(lockDir, "metadata"), "utf8"); - const pidMatch = meta.match(/^pid=(\d+)$/m); - if (pidMatch) { - const pid = Number(pidMatch[1]); - try { process.kill(pid, 0); return false; } catch { /* stale */ } - } - const acquiredMatch = meta.match(/^acquired_at=(.+)$/m); - if (acquiredMatch) { - const age = Date.now() - new Date(acquiredMatch[1]).getTime(); - if (age < lockTtlMs) return false; - } - rmSync(lockDir, { recursive: true, force: true }); - mkdirSync(lockDir, { recursive: false }); - writeFileSync(join(lockDir, "metadata"), `pid=${process.pid} -run_id=${runId} -acquired_at=${nowIso()} -`); - return true; - } catch { return false; } - } -} - -function releaseLock(): void { - try { rmSync(lockDir, { recursive: true, force: true }); } catch { /* best effort */ } -} - -const forwardActions = process.env.ZETA_RIVEN_LOOP_FORWARD_ACTIONS === "1"; -const forwardIntervalMs = Number(process.env.ZETA_RIVEN_LOOP_FORWARD_INTERVAL_SECONDS ?? "300") * 1000; -const forwardStateFile = join(stateDir, "last-forward-run.json"); -const broadcastDir = join(home, ".local/share/zeta-broadcasts"); - -function readBroadcasts(): void { - for (const peer of ["otto.md", "vera.md", "lior.md"]) { - const path = join(broadcastDir, peer); - if (existsSync(path)) { - const content = readFileSync(path, "utf8").trim(); - if (content) log(`broadcast from ${peer.replace(".md", "")}: ${content.split(" -")[0] ?? "(empty)"}`); - } - } -} - -function writeBroadcast(summary: string): void { - mkdirSync(broadcastDir, { recursive: true }); - writeFileSync(join(broadcastDir, "riven.md"), [ - `# Riven broadcast — ${nowIso()}`, - "", - "## Background tick status", - summary, - ].join(" -")); -} - -function gh(...args: string[]): { status: number; stdout: string } { - const r = spawnSync("gh", args, { - cwd: worktree, - encoding: "utf8", - timeout: 60_000, - env: { - ...process.env, - PATH: `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${join(home, ".local/bin")}`, - }, - }); - return { status: r.status ?? 1, stdout: r.stdout ?? "" }; -} - -function forwardTick(): void { - readBroadcasts(); - - const dirty = run("git", ["status", "--porcelain"], 10_000); - const dirtyCount = lines(dirty.stdout).length; - if (dirtyCount > 0) { - log(`forward: skip, dirty=${dirtyCount}`); - writeBroadcast(`Forward tick ${runId}: skip — dirty tree (${dirtyCount} files).`); - return; - } - - const prsResult = gh( - "pr", "list", "--repo", "Lucent-Financial-Group/Zeta", - "--state", "open", "--json", "number", "--jq", ".[].number" - ); - if (prsResult.status !== 0) { - log(`forward: gh pr list failed status=${prsResult.status}`); - writeBroadcast(`Forward tick ${runId}: gh pr list failed.`); - return; - } - - const prNumbers = prsResult.stdout.trim().split(" -").filter(n => n.trim()).map(Number); - for (const pr of prNumbers) { - const gateResult = gh( - "pr", "view", String(pr), "--repo", "Lucent-Financial-Group/Zeta", - "--json", "mergeStateStatus,autoMergeRequest,reviewThreads", - "--jq", "{mergeState: .mergeStateStatus, autoMerge: (.autoMergeRequest != null), unresolvedThreads: ([.reviewThreads[]? | select(.isResolved == false)] | length)}" - ); - if (gateResult.status !== 0) continue; - try { - const gate = JSON.parse(gateResult.stdout); - if (gate.mergeState === "CLEAN" && !gate.autoMerge && gate.unresolvedThreads === 0) { - log(`forward: arming auto-merge on PR #${pr}`); - gh("pr", "merge", String(pr), "--repo", "Lucent-Financial-Group/Zeta", "--squash", "--auto"); - writeBroadcast(`Forward tick ${runId}: armed auto-merge on PR #${pr}.`); - writeFileSync(forwardStateFile, JSON.stringify({ run_id: runId, updated_at: nowIso() }, null, 2)); - return; - } - } catch { continue; } - } - - log(`forward: no actionable PR found`); - writeBroadcast(`Forward tick ${runId}: idle — no actionable PR. ${prNumbers.length} open.`); - writeFileSync(forwardStateFile, JSON.stringify({ run_id: runId, updated_at: nowIso() }, null, 2)); -} - -function heartbeat(): void { - const fetch = run("git", ["fetch", "origin"], fetchTimeoutMs); - const fetchOk = fetch.status === 0 ? "ok" : `exit-${fetch.status}`; - - const claims = run("git", ["branch", "-r", "--list", "origin/claim/*"], 10_000); - const claimCount = lines(claims.stdout).length; - - const prs = run("gh", ["pr", "list", "--state", "open", "--json", "number", "--jq", "length"], 30_000); - const prCount = prs.stdout.trim() || "?"; - - const dirty = run("git", ["status", "--porcelain"], 10_000); - const dirtyCount = lines(dirty.stdout).length; - - const hbDir = join(worktree, ".git/agent-heartbeats"); - mkdirSync(hbDir, { recursive: true }); - const hbFile = join(hbDir, "riven-launchd-loop.json"); - - let agentStatus = "wait"; - let dueIn = ""; - - if (runAgent) { - let lastRun: { updated_at?: string } = {}; - try { lastRun = JSON.parse(readFileSync(agentStateFile, "utf8")); } catch { /* first run */ } - const lastTime = lastRun.updated_at ? new Date(lastRun.updated_at).getTime() : 0; - const elapsed = Date.now() - lastTime; - - if (elapsed >= agentIntervalMs) { - agentStatus = "running"; - log(`riven agent gate start run_id=${runId}`); - - const agentBin = dryRun ? null : resolveAgentBin(); - if (dryRun) { - log(`dry-run: would run agent gate`); - agentStatus = "dry-run"; - } else if (!agentBin) { - log(`riven agent gate skipped run_id=${runId} reason=no-agent-binary-on-PATH candidates=${agentBinCandidates.join(",")}`); - agentStatus = "no-bin"; - writeFileSync(agentStateFile, JSON.stringify({ - run_id: runId, - status: -1, - started_at: nowIso(), - updated_at: nowIso(), - skipped_reason: "no-agent-binary-on-PATH", - candidates: agentBinCandidates, - }, null, 2)); - } else { - log(`riven agent gate using bin=${agentBin}`); - const gate = run(agentBin, [ - "chat", - "--mode", "ask", - "--model", "grok-4.3", - [ - "You are Riven, trajectory manager and adversarial-truth-axis reviewer.", - "This is an autonomous 15-minute cycle.", - "Read broadcasts first from ~/.local/share/zeta-broadcasts/{otto,vera,lior,riven}.md.", - "Walk assigned trajectories. Decompose only what you hit mid-stride.", - "Produce at least one concrete, actionable claim or small PR scope.", - "When blocked, create a specific research child the next pickup cannot dodge.", - "Write your status to ~/.local/share/zeta-broadcasts/riven.md at the end.", - "GitHub PR state and actual file contents are authoritative.", - ].join(" "), - ], agentTimeoutMs); - - agentStatus = gate.status === 0 ? "ok" : `exit-${gate.status}`; - log(`riven agent gate end run_id=${runId} status=${gate.status}`); - - writeFileSync(agentStateFile, JSON.stringify({ - run_id: runId, - status: gate.status, - started_at: nowIso(), - updated_at: nowIso(), - }, null, 2)); - - if (gate.stdout.trim().length > 0) { - appendFileSync(join(logDir, "ticks.log"), ` ---- ${runId} riven gate --- -${gate.stdout} -`); - } - if (gate.stderr.trim().length > 0) { - appendFileSync(join(logDir, "ticks.err"), ` ---- ${runId} riven gate --- -${gate.stderr} -`); - } - } - } else { - const remaining = Math.round((agentIntervalMs - elapsed) / 1000); - dueIn = `due_in=${remaining}s`; - agentStatus = "wait"; - } - } - - let forwardStatus = "disabled"; - if (forwardActions && fetchOk === "ok") { - let lastForward: { updated_at?: string } = {}; - try { lastForward = JSON.parse(readFileSync(forwardStateFile, "utf8")); } catch { /* first run */ } - const forwardElapsed = Date.now() - (lastForward.updated_at ? new Date(lastForward.updated_at).getTime() : 0); - if (forwardElapsed >= forwardIntervalMs) { - log(`forward-tick start run_id=${runId}`); - forwardTick(); - forwardStatus = "ok"; - log(`forward-tick end run_id=${runId}`); - } else { - forwardStatus = `wait due_in=${Math.round((forwardIntervalMs - forwardElapsed) / 1000)}s`; - } - } - - const summary = `heartbeat complete run_id=${runId} fetch=${fetchOk} claims=${claimCount} open_prs=${prCount} dirty=${dirtyCount} riven=${agentStatus} forward=${forwardStatus} ${dueIn}`.trim(); - log(summary); - - writeFileSync(hbFile, JSON.stringify({ - session: "cursor-grok/riven-launchd-loop", - harness: "cursor-grok", - claim: "host-loop", - branch: "main", - worktree, - paths: ["(host-level heartbeat — adversarial-truth-axis)"], - updated_at: nowIso(), - status: "active", - dirty_count: String(dirtyCount), - }, null, 2)); -} - -if (!acquireLock()) { - log(`skip: lock held by another tick run_id=${runId}`); - process.exit(0); -} - -try { - heartbeat(); -} catch (err) { - log(`error: ${err instanceof Error ? err.message : String(err)}`); -} finally { - releaseLock(); -}