diff --git a/AGENTS.md b/AGENTS.md index ae8a1ba6..ee495cb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,46 +244,60 @@ When you create a PR or start work, leave a clear signal: - **PR title prefix**: Use a descriptive prefix like `[nostr-core]`, `[playback]`, `[ui]`, `[ci]` so the scope is visible at a glance. - **PR description**: Include a "Files Modified" section listing the key files touched, so other agents can quickly detect conflicts. -### Task Claiming Protocol (Draft PR Lock) +### Task Claiming Protocol (Distributed Lock) -To prevent multiple agents from working on the same task simultaneously, every agent must **check open PRs and create a claim** before starting work. The claim (pushed branch + `started` log file) acts as a distributed lock visible to all agents across all platforms (Claude Code, Codex, Jules). +To prevent multiple agents from working on the same task simultaneously, every agent must **check for existing locks and create a claim** before starting work. The lock is broadcast via Nostr relays (primary) or GitHub PRs (fallback), making it visible to all agents across all platforms (Claude Code, Codex, Jules). **Before starting any task:** -1. **Check for existing claims.** Search for open PRs matching the task: +1. **Check for existing claims.** Run both checks and merge the exclusion sets: + + **Nostr lock check (primary):** + ```bash + node scripts/agent/nostr-lock.mjs check --cadence + ``` + Returns JSON with `locked` (claimed agents) and `available` (free agents) arrays. + + **GitHub PR check (supplemental):** ``` curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '{count: length, titles: [.[].title]}' ``` - If a matching PR exists (open or draft) for the same agent or subsystem, **skip the task** — another agent is already working on it. + If an agent appears in either the Nostr lock list or has an open PR, **skip that agent**. -2. **Claim the task.** If no matching PR exists, use one of these methods: +2. **Claim the task.** Use one of these methods (try in order): - **Method A — git push (preferred when available):** + **Method A — Nostr lock (preferred, works everywhere Node.js is available):** + ```bash + AGENT_PLATFORM= \ + node scripts/agent/nostr-lock.mjs lock \ + --agent \ + --cadence + ``` + Generates an ephemeral keypair, publishes a NIP-78 lock event with NIP-40 auto-expiration (2 hours) to public Nostr relays. No tokens or secrets needed — the key is used once and discarded. The script includes a built-in race check: exit code 0 = lock acquired, exit code 3 = lost race (another agent claimed first). + + **Method B — git push (fallback when relays are unreachable):** a. Create your working branch. b. Make a minimal initial commit (e.g., create a `context/CONTEXT_.md` with the task scope). c. Create a `started` log file in the appropriate task-log directory. d. Push the branch immediately to make the claim visible. e. Only after the branch is pushed and the claim is visible, begin the actual work. - **Method B — GitHub API fallback (when git push is blocked):** - If `git push` is unavailable (e.g., Jules sandbox), use the API claim script: + **Method C — GitHub API (fallback when git push is also blocked):** ```bash bash scripts/agent/claim-task-api.sh \ --agent \ --cadence \ --base unstable ``` - This creates the remote branch, `_started.md` log file, and draft PR entirely via `curl` and the GitHub REST API. Requires `GITHUB_TOKEN` or `GH_TOKEN` in the environment. The script outputs `CLAIM_BRANCH`, `CLAIM_PR_NUMBER`, and `CLAIM_PR_URL` for use in subsequent steps. + Creates the remote branch, `_started.md` log file, and draft PR via `curl` and the GitHub REST API. Requires `GITHUB_TOKEN` or `GH_TOKEN` in the environment. - If **neither method** works, do not proceed — write a `_failed.md` log and stop. + If **no method** works, do not proceed — write a `_failed.md` log and stop. -3. **Handle race conditions.** After your claim is visible remotely, re-check for duplicates: - ``` - curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '{count: length, titles: [.[].title]}' - ``` - If you find another agent's PR for the same task that was created **before** yours, abandon your branch and skip the task. +3. **Handle race conditions.** + - If Method A (Nostr) was used, the race check is automatic (built into the script). + - If Method B or C was used, re-check for duplicates via the GitHub PR preflight. If you find another agent's claim that was created **before** yours, abandon your branch and skip the task. -**Scheduler agents:** Daily and weekly scheduler agents must follow this protocol in addition to their directory-based rotation logic (`docs/agents/task-logs/daily/` and `docs/agents/task-logs/weekly/`). The draft PR check happens _after_ determining the next task but _before_ executing it. See the scheduler prompts for the specific implementation steps. +**Scheduler agents:** Daily and weekly scheduler agents must follow this protocol in addition to their directory-based rotation logic (`docs/agents/task-logs/daily/` and `docs/agents/task-logs/weekly/`). The lock check happens _after_ determining the next task but _before_ executing it. See the scheduler prompts for the specific implementation steps. ### Currently In-Flight Work diff --git a/docs/agents/prompts/META_PROMPTS.md b/docs/agents/prompts/META_PROMPTS.md index ccd36885..7ac4d2fe 100644 --- a/docs/agents/prompts/META_PROMPTS.md +++ b/docs/agents/prompts/META_PROMPTS.md @@ -17,25 +17,31 @@ MUST 1: Set cadence config to: - branch_prefix = agents/daily/ - prompt_dir = docs/agents/prompts/daily/ -MUST 2: Run this exact preflight command first: +MUST 2: Run BOTH preflight commands to build the exclusion set: +a) Nostr lock check (primary): +node scripts/agent/nostr-lock.mjs check --cadence daily + +b) GitHub PR check (supplemental): curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '{count: length, prs: [.[] | {number, created_at, draft, head: {ref: .head.ref}, agent: ((.head.ref | capture("^agents/daily/(?[^/]+)/")?.agent) // (.title | capture("(?[A-Za-z0-9-]+-agent)")?.agent // null))}] | sort_by(.created_at, .number)}' +Exclusion set = union of Nostr locked agents + agents with open PRs. + MUST 3: Run the exact command list in this order: 1) cat AGENTS.md CLAUDE.md 2) ls -1 docs/agents/task-logs/daily/ | sort | tail -n 1 -3) [claim] Try: create branch, create+commit `_started.md`, push. - IF git push fails or is blocked, use the API fallback instead: +3) [claim] Use the Nostr lock (preferred, no tokens needed): + AGENT_PLATFORM=jules node scripts/agent/nostr-lock.mjs lock --agent --cadence daily + Exit 0 = lock acquired. Exit 3 = race lost, go back to step 2. + IF Nostr relays are unreachable, try git push fallback. + IF git push also fails, try: bash scripts/agent/claim-task-api.sh --agent --cadence daily --base unstable - This creates the remote branch, started.md, and draft PR via curl. - Requires GITHUB_TOKEN or GH_TOKEN in environment. - IF both methods fail, write `_failed.md` and stop. -4) rerun preflight command from MUST 2 -5) execute selected prompt from docs/agents/prompts/daily/ -6) npm run lint -7) create `_completed.md` or `_failed.md`, commit, push (or API update) + IF all methods fail, write `_failed.md` and stop. +4) execute selected prompt from docs/agents/prompts/daily/ +5) npm run lint +6) create `_completed.md` or `_failed.md`, commit, push (or API update) -MUST 4: Race rule: after step 3, compare only matching derived `agent` claims from the rerun. Earlier `created_at` wins; if tied, lower PR number wins. Print exactly one line: `RACE CHECK: won` or `RACE CHECK: lost (agent already claimed by PR #)`. +MUST 4: Race rule: the Nostr lock script handles race detection automatically (exit code 3 = lost). For git push/API fallbacks: rerun GitHub PR preflight after claiming. Earlier `created_at` wins; if tied, lower PR number wins. Print exactly one line: `RACE CHECK: won` or `RACE CHECK: lost`. MUST 5: If all daily agents are excluded, stop and write `_failed.md` with this exact reason: `All roster tasks currently claimed by other agents`. ``` @@ -53,25 +59,31 @@ MUST 1: Set cadence config to: - branch_prefix = agents/weekly/ - prompt_dir = docs/agents/prompts/weekly/ -MUST 2: Run this exact preflight command first: +MUST 2: Run BOTH preflight commands to build the exclusion set: +a) Nostr lock check (primary): +node scripts/agent/nostr-lock.mjs check --cadence weekly + +b) GitHub PR check (supplemental): curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '{count: length, prs: [.[] | {number, created_at, draft, head: {ref: .head.ref}, agent: ((.head.ref | capture("^agents/weekly/(?[^/]+)/")?.agent) // (.title | capture("(?[A-Za-z0-9-]+-agent)")?.agent // null))}] | sort_by(.created_at, .number)}' +Exclusion set = union of Nostr locked agents + agents with open PRs. + MUST 3: Run the exact command list in this order: 1) cat AGENTS.md CLAUDE.md 2) ls -1 docs/agents/task-logs/weekly/ | sort | tail -n 1 -3) [claim] Try: create branch, create+commit `_started.md`, push. - IF git push fails or is blocked, use the API fallback instead: +3) [claim] Use the Nostr lock (preferred, no tokens needed): + AGENT_PLATFORM=jules node scripts/agent/nostr-lock.mjs lock --agent --cadence weekly + Exit 0 = lock acquired. Exit 3 = race lost, go back to step 2. + IF Nostr relays are unreachable, try git push fallback. + IF git push also fails, try: bash scripts/agent/claim-task-api.sh --agent --cadence weekly --base unstable - This creates the remote branch, started.md, and draft PR via curl. - Requires GITHUB_TOKEN or GH_TOKEN in environment. - IF both methods fail, write `_failed.md` and stop. -4) rerun preflight command from MUST 2 -5) execute selected prompt from docs/agents/prompts/weekly/ -6) npm run lint -7) create `_completed.md` or `_failed.md`, commit, push (or API update) + IF all methods fail, write `_failed.md` and stop. +4) execute selected prompt from docs/agents/prompts/weekly/ +5) npm run lint +6) create `_completed.md` or `_failed.md`, commit, push (or API update) -MUST 4: Race rule: after step 3, compare only matching derived `agent` claims from the rerun. Earlier `created_at` wins; if tied, lower PR number wins. Print exactly one line: `RACE CHECK: won` or `RACE CHECK: lost (agent already claimed by PR #)`. +MUST 4: Race rule: the Nostr lock script handles race detection automatically (exit code 3 = lost). For git push/API fallbacks: rerun GitHub PR preflight after claiming. Earlier `created_at` wins; if tied, lower PR number wins. Print exactly one line: `RACE CHECK: won` or `RACE CHECK: lost`. MUST 5: If all weekly agents are excluded, stop and write `_failed.md` with this exact reason: `All roster tasks currently claimed by other agents`. ``` diff --git a/docs/agents/prompts/scheduler-flow.md b/docs/agents/prompts/scheduler-flow.md index 703827e4..67678261 100644 --- a/docs/agents/prompts/scheduler-flow.md +++ b/docs/agents/prompts/scheduler-flow.md @@ -27,14 +27,24 @@ During scheduler execution, the scheduler-specific instructions in this document - `branch_prefix` = `agents//` - `prompt_dir` = `docs/agents/prompts//` -2. **MUST** run this command first and save the JSON output as the preflight exclusion set: +2. **MUST** run the preflight check to build the exclusion set. Use the Nostr lock check (preferred) with GitHub PR check as supplemental: + **Primary — Nostr lock check:** + ```bash + node scripts/agent/nostr-lock.mjs check --cadence + ``` + This queries Nostr relays for active lock events and returns JSON with `locked` and `available` agent lists. + + **Supplemental — GitHub PR check** (catches agents with open PRs that predate the Nostr system): ```bash curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '{count: length, prs: [.[] | {number, created_at, draft, head: {ref: .head.ref}, agent: ((.head.ref | capture("^agents//(?[^/]+)/")?.agent) // (.title | capture("(?[A-Za-z0-9-]+-agent)")?.agent // null))}] | sort_by(.created_at, .number)}' ``` -3. **MUST** derive excluded agents from command output using this order only: - 1) parse `head.ref` as `agents///...`; 2) fallback to title regex; 3) if still unknown, stop with a global lock message. + The exclusion set is the **union** of agents locked via Nostr AND agents with open PRs. + +3. **MUST** derive excluded agents from both outputs: + - From Nostr check: use the `locked` array directly. + - From GitHub PR check: 1) parse `head.ref` as `agents///...`; 2) fallback to title regex; 3) if still unknown, stop with a global lock message. 4. **MUST** run this command and read both policy files before selecting an agent: @@ -42,7 +52,7 @@ During scheduler execution, the scheduler-specific instructions in this document cat AGENTS.md CLAUDE.md ``` -5. **MUST** run this command to identify the latest cadence log file, then choose the next roster agent not excluded by Step 2: +5. **MUST** run this command to identify the latest cadence log file, then choose the next roster agent not in the exclusion set: ```bash ls -1 | sort | tail -n 1 @@ -52,24 +62,34 @@ During scheduler execution, the scheduler-specific instructions in this document 7. **MUST** claim the selected agent using **one** of these methods (try in order): - **Method A — git push (preferred):** + **Method A — Nostr lock (preferred, works everywhere Node.js is available):** + ```bash + AGENT_PLATFORM= \ + node scripts/agent/nostr-lock.mjs lock \ + --agent \ + --cadence + ``` + This generates an ephemeral keypair, publishes a NIP-78 lock event to Nostr relays with NIP-40 auto-expiration (2 hours default), and performs a built-in race check. No tokens or secrets needed. Exit code 0 = won, exit code 3 = lost race. + + **Method B — git push (when Nostr relays are unreachable):** 1) create branch, 2) create and commit `__started.md` in ``, 3) push branch and commit. - **Method B — GitHub API fallback (when git push is blocked):** - If `git push` fails or is unavailable (e.g., Jules sandbox), use the API claim script: + **Method C — GitHub API fallback (when git push is also blocked):** ```bash bash scripts/agent/claim-task-api.sh \ --agent \ --cadence \ --base unstable ``` - This creates the remote branch, `_started.md` log file, and draft PR entirely via the GitHub REST API using `curl`. Requires `GITHUB_TOKEN` or `GH_TOKEN` in the environment. + Requires `GITHUB_TOKEN` or `GH_TOKEN` in the environment. - If **both** methods fail (no push access AND no API token), write a `_failed.md` log with reason `Cannot create remote claim: git push blocked and no GITHUB_TOKEN available` and stop. + If **all three methods** fail, write a `_failed.md` log with reason `Cannot create remote claim: all locking methods failed` and stop. -8. **MUST** re-run the same PR preflight command from Step 2 immediately after the claim is visible remotely. If an earlier claim exists for the same derived agent, print `RACE CHECK: lost (agent already claimed by PR #)`, abandon the run, and return to Step 5. Otherwise print `RACE CHECK: won`. +8. **MUST** verify the lock was acquired: + - If Method A was used: the script's built-in race check already ran. If exit code was 0, print `RACE CHECK: won`. If exit code 3, print `RACE CHECK: lost`, abandon, and return to Step 5. + - If Method B or C was used: re-run the GitHub PR preflight from Step 2. If an earlier claim exists for the same derived agent, print `RACE CHECK: lost (agent already claimed by PR #)`, abandon, and return to Step 5. 9. **MUST** execute `/` end-to-end. @@ -77,71 +97,81 @@ During scheduler execution, the scheduler-specific instructions in this document ## Canonical Example Run Output -### Method A (git push) +### Method A — Nostr lock (preferred) ```text -$ curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '{count: length, prs: [.[] | {number, created_at, draft, head: {ref: .head.ref}, agent: ((.head.ref | capture("^agents/daily/(?[^/]+)/")?.agent) // (.title | capture("(?[A-Za-z0-9-]+-agent)")?.agent // null))}] | sort_by(.created_at, .number)}' -{ "count": 12, "prs": [ ... ] } +# Step 2: Preflight — check Nostr locks + GitHub PRs +$ node scripts/agent/nostr-lock.mjs check --cadence daily +{ "cadence": "daily", "date": "2026-02-14", "locked": ["audit-agent"], "available": [...], ... } +$ curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '...' +{ "count": 2, "prs": [ ... ] } + +# Steps 3-5: Read policies, check logs, select agent $ cat AGENTS.md CLAUDE.md -# bitvid — AI Agent Guide ... $ ls -1 docs/agents/task-logs/daily/ | sort | tail -n 1 2026-02-13_18-40-00_load-test-agent_completed.md +Selected agent: nip-research-agent + +# Step 7: Claim via Nostr lock +$ AGENT_PLATFORM=jules node scripts/agent/nostr-lock.mjs lock --agent nip-research-agent --cadence daily +Locking: agent=nip-research-agent, cadence=daily, date=2026-02-14 +Step 1: Checking for existing locks... +Step 2: Generating ephemeral keypair... +Step 3: Building lock event... +Step 4: Publishing to relays... + Published to 3/3 relays +Step 5: Race check... +RACE CHECK: won +LOCK_STATUS=ok +LOCK_EVENT_ID=ea2724c76d2bd707... +LOCK_AGENT=nip-research-agent +LOCK_CADENCE=daily +LOCK_EXPIRES_ISO=2026-02-14T02:00:00.000Z + +# Step 9: Execute agent prompt +(agent runs nip-research-agent prompt...) + +# Step 10: Finalize +$ npm run lint +> lint passed + +Wrote: docs/agents/task-logs/daily/2026-02-14_00-12-00_nip-research-agent_completed.md +``` + +### Method B — git push (fallback if relays are unreachable) + +```text +$ node scripts/agent/nostr-lock.mjs check --cadence daily +nostr-lock failed: Relay query timed out +# Nostr relays unreachable — fall back to GitHub PR check only + +$ curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '...' +{ "count": 12, "prs": [ ... ] } + +$ cat AGENTS.md CLAUDE.md +... + Selected agent: nip-research-agent Created: docs/agents/task-logs/daily/2026-02-14_00-00-00_nip-research-agent_started.md Pushed branch: agents/daily/nip-research-agent/2026-02-14-run $ curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '...' -{ "count": 13, "prs": [ ... ] } RACE CHECK: won - -$ npm run lint -> lint passed - -Wrote: docs/agents/task-logs/daily/2026-02-14_00-12-00_nip-research-agent_completed.md ``` -### Method B (API fallback — for Jules or other restricted environments) +### Method C — GitHub API (fallback if git push is also blocked) ```text -$ curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '...' -{ "count": 12, "prs": [ ... ] } - -$ cat AGENTS.md CLAUDE.md -... - -$ ls -1 docs/agents/task-logs/daily/ | sort | tail -n 1 -2026-02-13_18-40-00_load-test-agent_completed.md - -Selected agent: nip-research-agent - -# git push is blocked — use API fallback: +# Both Nostr relays and git push failed $ bash scripts/agent/claim-task-api.sh --agent nip-research-agent --cadence daily --base unstable -Claiming task: nip-research-agent (daily) -Branch: agents/daily/nip-research-agent/2026-02-14-run-1707868800 -Step 1: Fetching base branch SHA (unstable)... - Base SHA: abc123... -Step 2: Creating remote branch... - Created: refs/heads/agents/daily/nip-research-agent/2026-02-14-run-1707868800 -Step 3: Creating started.md log file... - Created: docs/agents/task-logs/daily/2026-02-14_00-00-00_nip-research-agent_started.md -Step 4: Creating draft PR... - PR : CLAIM_BRANCH=agents/daily/nip-research-agent/2026-02-14-run-1707868800 CLAIM_PR_NUMBER= CLAIM_PR_URL= -CLAIM_LOG_FILE=docs/agents/task-logs/daily/2026-02-14_00-00-00_nip-research-agent_started.md -# Re-run preflight to check for race: $ curl -s "https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100" | jq '...' -{ "count": 13, "prs": [ ... ] } RACE CHECK: won - -$ npm run lint -> lint passed - -Wrote: docs/agents/task-logs/daily/2026-02-14_00-12-00_nip-research-agent_completed.md ``` diff --git a/scripts/agent/nostr-lock.mjs b/scripts/agent/nostr-lock.mjs new file mode 100755 index 00000000..d3b02942 --- /dev/null +++ b/scripts/agent/nostr-lock.mjs @@ -0,0 +1,517 @@ +#!/usr/bin/env node + +// scripts/agent/nostr-lock.mjs +// +// Nostr-based distributed task locking for multi-agent coordination. +// +// Each lock generates a fresh ephemeral keypair and discards it. +// Locks auto-expire via NIP-40 — no cleanup needed. +// Other agents discover locks by querying relays for bitvid lock events. +// +// Commands: +// check --cadence JSON list of locked agents +// lock --agent --cadence Claim a lock (exit 0=won, 3=lost) +// list [--cadence ] Human-readable lock table +// +// Environment: +// NOSTR_LOCK_RELAYS Comma-separated relay WSS URLs (overrides defaults) +// NOSTR_LOCK_TTL Lock TTL in seconds (default: 7200 = 2 hours) + +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { SimplePool } from 'nostr-tools/pool'; +import { useWebSocketImplementation } from 'nostr-tools/relay'; +import WebSocket from 'ws'; + +useWebSocketImplementation(WebSocket); + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.primal.net', +]; + +const DEFAULT_TTL = 7200; // 2 hours in seconds +const QUERY_TIMEOUT_MS = 15_000; +const VALID_CADENCES = new Set(['daily', 'weekly']); + +const DAILY_ROSTER = [ + 'audit-agent', + 'ci-health-agent', + 'const-refactor-agent', + 'content-audit-agent', + 'decompose-agent', + 'deps-security-agent', + 'design-system-audit-agent', + 'docs-agent', + 'docs-alignment-agent', + 'docs-code-investigator', + 'innerhtml-migration-agent', + 'known-issues-agent', + 'load-test-agent', + 'nip-research-agent', + 'onboarding-audit-agent', + 'perf-agent', + 'prompt-curator-agent', + 'scheduler-update-agent', + 'style-agent', + 'test-audit-agent', + 'todo-triage-agent', +]; + +const WEEKLY_ROSTER = [ + 'bug-reproducer-agent', + 'changelog-agent', + 'dead-code-agent', + 'event-schema-agent', + 'frontend-console-debug-agent', + 'fuzz-agent', + 'interop-agent', + 'perf-deepdive-agent', + 'perf-optimization-agent', + 'pr-review-agent', + 'race-condition-agent', + 'refactor-agent', + 'smoke-agent', + 'telemetry-agent', + 'test-coverage-agent', + 'weekly-synthesis-agent', +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getRelays() { + const envRelays = process.env.NOSTR_LOCK_RELAYS; + if (envRelays) { + return envRelays + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + } + return DEFAULT_RELAYS; +} + +function getTtl() { + const envTtl = process.env.NOSTR_LOCK_TTL; + if (envTtl) { + const parsed = parseInt(envTtl, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + return DEFAULT_TTL; +} + +function todayDateStr() { + return new Date().toISOString().slice(0, 10); // YYYY-MM-DD +} + +function todayStartUnix() { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + return Math.floor(d.getTime() / 1000); +} + +function nowUnix() { + return Math.floor(Date.now() / 1000); +} + +function getRoster(cadence) { + return cadence === 'daily' ? DAILY_ROSTER : WEEKLY_ROSTER; +} + +/** Parse a lock event into a structured object. */ +function parseLockEvent(event) { + const dTag = event.tags.find((t) => t[0] === 'd')?.[1] ?? ''; + const expTag = event.tags.find((t) => t[0] === 'expiration')?.[1]; + const expiresAt = expTag ? parseInt(expTag, 10) : null; + + let content = {}; + try { + content = JSON.parse(event.content); + } catch { + // content might not be valid JSON + } + + return { + eventId: event.id, + pubkey: event.pubkey, + createdAt: event.created_at, + createdAtIso: new Date(event.created_at * 1000).toISOString(), + expiresAt, + expiresAtIso: expiresAt ? new Date(expiresAt * 1000).toISOString() : null, + dTag, + agent: content.agent ?? null, + cadence: content.cadence ?? null, + status: content.status ?? null, + date: content.date ?? null, + platform: content.platform ?? null, + }; +} + +/** Filter out expired locks. */ +function filterActiveLocks(locks) { + const now = nowUnix(); + return locks.filter((lock) => { + if (lock.expiresAt && lock.expiresAt <= now) return false; + return true; + }); +} + +// --------------------------------------------------------------------------- +// Relay operations +// --------------------------------------------------------------------------- + +/** + * Query relays for lock events matching a cadence and date. + * Returns parsed lock objects. + */ +async function queryLocks(relays, cadence, dateStr) { + const pool = new SimplePool(); + const tagFilter = `bitvid-lock-${cadence}-${dateStr}`; + + try { + const events = await Promise.race([ + pool.querySync(relays, { + kinds: [30078], + '#t': [tagFilter], + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Relay query timed out')), QUERY_TIMEOUT_MS), + ), + ]); + + return filterActiveLocks(events.map(parseLockEvent)); + } finally { + pool.close(relays); + } +} + +/** + * Publish a lock event to relays. + * Returns the signed event. + */ +async function publishLock(relays, event) { + const pool = new SimplePool(); + + try { + const publishPromises = pool.publish(relays, event); + const results = await Promise.allSettled(publishPromises); + + const successes = results.filter((r) => r.status === 'fulfilled'); + if (successes.length === 0) { + const errors = results.map((r, i) => { + if (r.status === 'rejected') { + const reason = r.reason; + const msg = reason instanceof Error ? reason.message : String(reason ?? 'unknown'); + return `${relays[i]}: ${msg}`; + } + return `${relays[i]}: unknown`; + }); + throw new Error(`Failed to publish to any relay:\n ${errors.join('\n ')}`); + } + + console.error(` Published to ${successes.length}/${relays.length} relays`); + return event; + } finally { + pool.close(relays); + } +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +async function cmdCheck(cadence) { + const relays = getRelays(); + const dateStr = todayDateStr(); + + console.error(`Checking locks: cadence=${cadence}, date=${dateStr}`); + console.error(`Relays: ${relays.join(', ')}`); + + const locks = await queryLocks(relays, cadence, dateStr); + const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; + const roster = getRoster(cadence); + const available = roster.filter((a) => !lockedAgents.includes(a)); + + const result = { + cadence, + date: dateStr, + locked: lockedAgents.sort(), + available: available.sort(), + lockCount: locks.length, + locks: locks.map((l) => ({ + agent: l.agent, + eventId: l.eventId, + createdAt: l.createdAtIso, + expiresAt: l.expiresAtIso, + platform: l.platform, + })), + }; + + // Machine-readable JSON to stdout + console.log(JSON.stringify(result, null, 2)); +} + +async function cmdLock(agent, cadence, dryRun = false) { + const relays = getRelays(); + const dateStr = todayDateStr(); + const ttl = getTtl(); + const now = nowUnix(); + const expiresAt = now + ttl; + + console.error(`Locking: agent=${agent}, cadence=${cadence}, date=${dateStr}`); + console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); + console.error(`Relays: ${relays.join(', ')}`); + + // Step 1: Check for existing locks on this agent + console.error('Step 1: Checking for existing locks...'); + const existingLocks = await queryLocks(relays, cadence, dateStr); + const conflicting = existingLocks.filter((l) => l.agent === agent); + + if (conflicting.length > 0) { + const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0]; + console.error( + `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` + + `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`, + ); + console.log(`LOCK_STATUS=denied`); + console.log(`LOCK_REASON=already_locked`); + console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); + process.exit(3); + } + + // Step 2: Generate ephemeral keypair + console.error('Step 2: Generating ephemeral keypair...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); + + // Step 3: Build and sign the lock event + console.error('Step 3: Building lock event...'); + const event = finalizeEvent( + { + kind: 30078, + created_at: now, + tags: [ + ['d', `bitvid-lock/${cadence}/${agent}/${dateStr}`], + ['t', 'bitvid-agent-lock'], + ['t', `bitvid-lock-${cadence}`], + ['t', `bitvid-lock-${cadence}-${dateStr}`], + ['expiration', String(expiresAt)], + ], + content: JSON.stringify({ + agent, + cadence, + status: 'started', + date: dateStr, + platform: process.env.AGENT_PLATFORM || 'unknown', + lockedAt: new Date(now * 1000).toISOString(), + expiresAt: new Date(expiresAt * 1000).toISOString(), + }), + }, + sk, + ); + + console.error(` Event ID: ${event.id}`); + + // Step 4: Publish to relays + if (dryRun) { + console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); + console.error('RACE CHECK: won (dry run — no real contention possible)'); + } else { + console.error('Step 4: Publishing to relays...'); + await publishLock(relays, event); + + // Step 5: Race check — re-query and verify we're the earliest + console.error('Step 5: Race check...'); + // Brief pause to let relays propagate + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const postLocks = await queryLocks(relays, cadence, dateStr); + const racingLocks = postLocks + .filter((l) => l.agent === agent) + .sort((a, b) => a.createdAt - b.createdAt); + + if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { + const winner = racingLocks[0]; + console.error( + `RACE CHECK: lost (earlier lock by event ${winner.eventId}, ` + + `created ${winner.createdAtIso})`, + ); + console.log(`LOCK_STATUS=race_lost`); + console.log(`LOCK_REASON=earlier_claim_exists`); + console.log(`LOCK_WINNER_EVENT=${winner.eventId}`); + process.exit(3); + } + + console.error('RACE CHECK: won'); + } + + // Output machine-readable results to stdout + console.log(`LOCK_STATUS=ok`); + console.log(`LOCK_EVENT_ID=${event.id}`); + console.log(`LOCK_PUBKEY=${pk}`); + console.log(`LOCK_AGENT=${agent}`); + console.log(`LOCK_CADENCE=${cadence}`); + console.log(`LOCK_DATE=${dateStr}`); + console.log(`LOCK_EXPIRES=${expiresAt}`); + console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); +} + +async function cmdList(cadence) { + const relays = getRelays(); + const dateStr = todayDateStr(); + + const cadences = cadence ? [cadence] : ['daily', 'weekly']; + console.error(`Listing active locks for: ${cadences.join(', ')}`); + + for (const c of cadences) { + const locks = await queryLocks(relays, c, dateStr); + + console.log(`\n${'='.repeat(72)}`); + console.log(`Active ${c} locks (${dateStr})`); + console.log('='.repeat(72)); + + if (locks.length === 0) { + console.log(' (no active locks)'); + continue; + } + + const sorted = locks.sort((a, b) => a.createdAt - b.createdAt); + for (const lock of sorted) { + const age = nowUnix() - lock.createdAt; + const ageMin = Math.round(age / 60); + const remaining = lock.expiresAt ? lock.expiresAt - nowUnix() : null; + const remainMin = remaining ? Math.round(remaining / 60) : '?'; + + console.log( + ` ${(lock.agent ?? 'unknown').padEnd(30)} ` + + `age: ${String(ageMin).padStart(4)}m ` + + `ttl: ${String(remainMin).padStart(4)}m ` + + `platform: ${lock.platform ?? '?'} ` + + `event: ${lock.eventId?.slice(0, 12)}...`, + ); + } + + const roster = getRoster(c); + const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean)); + const available = roster.filter((a) => !lockedAgents.has(a)); + console.log(`\n Locked: ${lockedAgents.size}/${roster.length}`); + console.log(` Available: ${available.join(', ') || '(none)'}`); + } +} + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +function parseArgs(argv) { + const args = { command: null, agent: null, cadence: null, dryRun: false }; + let i = 0; + + // First positional arg is the command + if (argv.length > 0 && !argv[0].startsWith('-')) { + args.command = argv[0]; + i = 1; + } + + for (; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--agent' || arg === '-a') { + args.agent = argv[++i]; + } else if (arg === '--cadence' || arg === '-c') { + args.cadence = argv[++i]; + } else if (arg.startsWith('--agent=')) { + args.agent = arg.split('=')[1]; + } else if (arg.startsWith('--cadence=')) { + args.cadence = arg.split('=')[1]; + } else if (arg === '--dry-run') { + args.dryRun = true; + } + } + + return args; +} + +function usage() { + console.error(`Usage: node scripts/agent/nostr-lock.mjs [options] + +Commands: + check --cadence Check which agents are locked (JSON) + lock --agent --cadence Claim a lock for an agent + list [--cadence ] Human-readable lock table + +Options: + --dry-run Build and sign the event but don't publish (for testing) + +Environment: + NOSTR_LOCK_RELAYS Comma-separated relay WSS URLs + NOSTR_LOCK_TTL Lock TTL in seconds (default: 7200) + AGENT_PLATFORM Platform identifier (e.g., "jules", "claude-code") + +Exit codes: + 0 Success (lock acquired or check complete) + 1 Usage error + 2 Relay/network error + 3 Lock denied (already locked or race lost)`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (!args.command) { + usage(); + process.exit(1); + } + + switch (args.command) { + case 'check': { + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error('ERROR: --cadence is required for check'); + process.exit(1); + } + await cmdCheck(args.cadence); + break; + } + + case 'lock': { + if (!args.agent) { + console.error('ERROR: --agent is required for lock'); + process.exit(1); + } + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error('ERROR: --cadence is required for lock'); + process.exit(1); + } + await cmdLock(args.agent, args.cadence, args.dryRun); + break; + } + + case 'list': { + if (args.cadence && !VALID_CADENCES.has(args.cadence)) { + console.error('ERROR: --cadence must be daily or weekly'); + process.exit(1); + } + await cmdList(args.cadence || null); + break; + } + + default: + console.error(`ERROR: Unknown command: ${args.command}`); + usage(); + process.exit(1); + } +} + +main().catch((err) => { + console.error(`nostr-lock failed: ${err.message}`); + process.exit(2); +});