mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(agents): add Nostr-based distributed task locking via NIP-78
Replace GitHub draft PR as the primary locking mechanism with Nostr relay-based locks. Each agent generates an ephemeral keypair, publishes a NIP-78 (Kind 30078) lock event with NIP-40 auto-expiration, and discards the key. No tokens, no secrets, no git push required. New script: scripts/agent/nostr-lock.mjs - check: query relays for active locks, return JSON exclusion set - lock: claim a task with built-in race detection (exit 3 = lost) - list: human-readable table of all active locks The locking fallback chain is now: 1. Nostr lock (no credentials needed, works in any sandbox) 2. git push + draft PR (traditional method) 3. GitHub API via curl (needs GITHUB_TOKEN) Updated scheduler-flow.md, META_PROMPTS.md, and AGENTS.md to document all three methods with Nostr as the preferred primary. https://claude.ai/code/session_01BpDsVSAkVWtJLnfKxsB9ib
This commit is contained in:
4 files changed
+662
-89
No files matched your search
@@ -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 <daily|weekly>
|
||||
```
|
||||
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=<jules|claude-code|codex> \
|
||||
node scripts/agent/nostr-lock.mjs lock \
|
||||
--agent <agent-name> \
|
||||
--cadence <daily|weekly>
|
||||
```
|
||||
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_<timestamp>.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 <agent-name> \
|
||||
--cadence <daily|weekly> \
|
||||
--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
|
||||
|
||||
|
||||
@@ -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>[^/]+)/")?.agent) // (.title | capture("(?<agent>[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 <agent-name> --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 <agent-name> --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 #<number>)`.
|
||||
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>[^/]+)/")?.agent) // (.title | capture("(?<agent>[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 <agent-name> --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 <agent-name> --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 #<number>)`.
|
||||
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`.
|
||||
```
|
||||
@@ -27,14 +27,24 @@ During scheduler execution, the scheduler-specific instructions in this document
|
||||
- `branch_prefix` = `agents/<cadence>/`
|
||||
- `prompt_dir` = `docs/agents/prompts/<cadence>/`
|
||||
|
||||
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 <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/<cadence>/(?<agent>[^/]+)/")?.agent) // (.title | capture("(?<agent>[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/<cadence>/<agent-name>/...`; 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/<cadence>/<agent-name>/...`; 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 <log_dir> | 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=<jules|claude-code|codex> \
|
||||
node scripts/agent/nostr-lock.mjs lock \
|
||||
--agent <agent-name> \
|
||||
--cadence <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 `<timestamp>_<agent>_started.md` in `<log_dir>`,
|
||||
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 <agent-name> \
|
||||
--cadence <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 #<number>)`, 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 #<number>)`, abandon, and return to Step 5.
|
||||
|
||||
9. **MUST** execute `<prompt_dir>/<prompt-file>` 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>[^/]+)/")?.agent) // (.title | capture("(?<agent>[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 <number>: <url>
|
||||
CLAIM_BRANCH=agents/daily/nip-research-agent/2026-02-14-run-1707868800
|
||||
CLAIM_PR_NUMBER=<number>
|
||||
CLAIM_PR_URL=<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
|
||||
```
|
||||
Executable
+517
@@ -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 <daily|weekly> JSON list of locked agents
|
||||
// lock --agent <name> --cadence <daily|weekly> Claim a lock (exit 0=won, 3=lost)
|
||||
// list [--cadence <daily|weekly>] 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 <command> [options]
|
||||
|
||||
Commands:
|
||||
check --cadence <daily|weekly> Check which agents are locked (JSON)
|
||||
lock --agent <name> --cadence <daily|weekly> Claim a lock for an agent
|
||||
list [--cadence <daily|weekly>] 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 <daily|weekly> is required for check');
|
||||
process.exit(1);
|
||||
}
|
||||
await cmdCheck(args.cadence);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'lock': {
|
||||
if (!args.agent) {
|
||||
console.error('ERROR: --agent <name> is required for lock');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.cadence || !VALID_CADENCES.has(args.cadence)) {
|
||||
console.error('ERROR: --cadence <daily|weekly> 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);
|
||||
});
|
||||
Reference in new issue
Block a user