Refactor torch-lock into an installable NPM library and CLI

- Rename `src/nostr-lock.mjs` to `src/lib.mjs` and export core functions.
- Create `bin/torch-lock.mjs` as the CLI entry point.
- Implement `dashboard` command in `src/lib.mjs` to serve the dashboard using Node.js.
- Update `package.json` to configure `bin`, `main`, and `files` for NPM packaging.
- Create `torch-config.example.json` as a template for consumers.
- Update tests and README.md to reflect the new structure.
- Exclude task-specific artifacts (logs, context) from the package while retaining prompts and docs.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-14 06:41:49 +00:00
1 parent b8d0372780
commit 35092b3d5a
7 files changed
+240 -117

No files matched your search

+57 -59
View File
@@ -2,77 +2,69 @@
TORCH is a portable Nostr-based task locking toolkit for multi-agent coordination.
## First-run quickstart
From the repository root:
## Installation
```bash
npm install
cp torch-config.json /path/to/your-project/torch-config.json # optional template step when vendoring TORCH
npm run lock:check:daily
AGENT_PLATFORM=codex npm run lock:lock -- --agent docs-agent --cadence daily
npm run lock:list
npm install torch-lock
```
### Project configuration (`torch-config.json`)
## Usage
Create a `torch-config.json` file in your project root (or set `TORCH_CONFIG_PATH`) to override defaults per repo:
### CLI
The `torch-lock` CLI allows you to check, lock, and list tasks.
```bash
# Check locks for a specific cadence
npx torch-lock check --cadence daily
# Lock a task for an agent
AGENT_PLATFORM=codex npx torch-lock lock --agent docs-agent --cadence daily
# List all active locks
npx torch-lock list
# Run the Dashboard
npx torch-lock dashboard
```
### Configuration (`torch-config.json`)
Create a `torch-config.json` file in your project root to configure TORCH for your repository. You can use the included `torch-config.example.json` as a template.
Configuration options:
- `nostrLock.namespace` (d-tag/tag namespace)
- `nostrLock.relays`
- `nostrLock.ttlSeconds`
- `nostrLock.queryTimeoutMs`
- `nostrLock.dailyRoster` / `nostrLock.weeklyRoster`
- `nostrLock.relays` (array of relay URLs)
- `nostrLock.ttlSeconds` (lock duration in seconds)
- `nostrLock.queryTimeoutMs` (timeout for relay queries)
- `nostrLock.dailyRoster` / `nostrLock.weeklyRoster` (explicit rosters)
- `dashboard.defaultCadenceView` (`daily`, `weekly`, or `all`)
- `dashboard.defaultStatusView` (`active` or `all`)
- `scheduler.firstPromptByCadence.daily` / `.weekly`
- `scheduler.firstPromptByCadence.daily` / `.weekly` (first prompt to run)
- `scheduler.paused.daily` / `.weekly` (array of agent names to pause locally)
Weekly alignment maintenance is available via `src/prompts/weekly/repo-fit-agent.md`, which is designed to keep TORCH defaults and docs tuned to the host repository over time.
### Dashboard
Default first-run behavior is configured so daily scheduling starts with `scheduler-update-agent`.
Optional dashboard:
The dashboard provides a live view of lock events.
```bash
npm run dashboard:serve
# then open http://localhost:4173/dashboard/
npx torch-lock dashboard --port 4173
# Open http://localhost:4173/dashboard/
```
## Included
- `src/nostr-lock.mjs` — Generic lock/check/list CLI
- `src/docs/TORCH.md` — Protocol summary and usage
- `src/prompts/` — Generic scheduler prompts and flow
- `skills/` — Repository-local skill guides for agent onboarding and repeatable workflows
- `examples/` — Optional scheduler overlay examples for adapting TORCH to downstream repositories
- `dashboard/index.html` — Static lock dashboard
## CLI dependencies
Declared in `package.json` and pinned:
- `[email protected]`
- `[email protected]`
## NPM script helpers
- `npm run lock:check:daily`
- `npm run lock:check:weekly`
- `npm run lock:list`
- `npm run lock:lock -- --agent <agent-name> --cadence <daily|weekly>`
- `npm run dashboard:serve`
## Environment variables
You can override configuration using environment variables:
- `NOSTR_LOCK_NAMESPACE`
- `NOSTR_LOCK_RELAYS`
- `NOSTR_LOCK_RELAYS` (comma-separated)
- `NOSTR_LOCK_TTL`
- `NOSTR_LOCK_QUERY_TIMEOUT_MS`
- `NOSTR_LOCK_DAILY_ROSTER`
- `NOSTR_LOCK_WEEKLY_ROSTER`
- `TORCH_CONFIG_PATH`
- `AGENT_PLATFORM`
- `NOSTR_LOCK_DAILY_ROSTER` (comma-separated list of agents)
- `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated list of agents)
- `TORCH_CONFIG_PATH` (path to config file)
- `AGENT_PLATFORM` (platform identifier, e.g. `codex`)
## Roster precedence
@@ -80,16 +72,22 @@ The lock CLI resolves roster names in this order:
1. `NOSTR_LOCK_DAILY_ROSTER` / `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated env overrides).
2. `torch-config.json` (`nostrLock.dailyRoster` / `nostrLock.weeklyRoster`).
3. `src/prompts/roster.json` (`daily` / `weekly` canonical scheduler roster).
4. Built-in fallback roster (used only if `src/prompts/roster.json` is unreadable).
3. `src/prompts/roster.json` (canonical scheduler roster included in the package).
4. Built-in fallback roster.
`lock --agent` validates names against the resolved cadence roster, and `check`/`list` report lock events whose agent names do not match scheduler roster entries exactly.
## Included Resources
- `src/lib.mjs` — Core library logic (can be imported in scripts)
- `src/docs/TORCH.md` — Protocol summary and usage
- `src/prompts/` — Generic scheduler prompts and flow
- `skills/` — Repository-local skill guides for agent onboarding and repeatable workflows
- `dashboard/` — Static lock dashboard assets
## Example
## NPM Scripts (for development)
```bash
NOSTR_LOCK_NAMESPACE=my-project \
AGENT_PLATFORM=codex \
node src/nostr-lock.mjs lock --agent docs-agent --cadence daily
```
If you are developing `torch-lock` itself:
- `npm run lock:check:daily`
- `npm run lock:check:weekly`
- `npm run lock:list`
- `npm run dashboard:serve`
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { main } from '../src/lib.mjs';
main(process.argv.slice(2));
+3 -16
View File
@@ -11,6 +11,9 @@
"nostr-tools": "2.19.4",
"ws": "8.19.0"
},
"bin": {
"torch-lock": "bin/torch-lock.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.0.0",
@@ -252,22 +255,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
+21 -7
View File
@@ -1,16 +1,30 @@
{
"name": "torch-lock",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "TORCH standalone lock CLI and optional dashboard",
"description": "TORCH standalone lock CLI and dashboard",
"main": "src/lib.mjs",
"bin": {
"torch-lock": "./bin/torch-lock.mjs"
},
"files": [
"bin/",
"src/lib.mjs",
"src/torch-config.mjs",
"src/prompts/",
"src/docs/",
"skills/",
"dashboard/",
"torch-config.example.json",
"README.md"
],
"scripts": {
"test": "node --test test/*.test.mjs",
"lock:check:daily": "node src/nostr-lock.mjs check --cadence daily",
"lock:check:weekly": "node src/nostr-lock.mjs check --cadence weekly",
"lock:list": "node src/nostr-lock.mjs list",
"lock:lock": "node src/nostr-lock.mjs lock",
"dashboard:serve": "python3 -m http.server 4173",
"lock:check:daily": "node bin/torch-lock.mjs check --cadence daily",
"lock:check:weekly": "node bin/torch-lock.mjs check --cadence weekly",
"lock:list": "node bin/torch-lock.mjs list",
"lock:lock": "node bin/torch-lock.mjs lock",
"dashboard:serve": "node bin/torch-lock.mjs dashboard",
"build": "node build.mjs",
"lint": "eslint ."
},
+129 -32
View File
@@ -5,6 +5,7 @@
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
@@ -25,6 +26,7 @@ const DEFAULT_NAMESPACE = 'torch';
const DEFAULT_QUERY_TIMEOUT_MS = 15_000;
const VALID_CADENCES = new Set(['daily', 'weekly']);
// If installed as a package, prompts/roster.json is relative to this file
const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json');
const FALLBACK_ROSTER = {
daily: [
@@ -72,6 +74,14 @@ const FALLBACK_ROSTER = {
let cachedCanonicalRoster = null;
// Custom Error class for controlled exits
class ExitError extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
}
function getRelays() {
const config = loadTorchConfig();
const envRelays = process.env.NOSTR_LOCK_RELAYS;
@@ -131,7 +141,8 @@ function loadCanonicalRoster() {
console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${ROSTER_FILE}`);
} catch (err) {
console.error(`WARNING: Failed to read roster file (${ROSTER_FILE}): ${err.message}`);
// It's okay if roster file is missing when used as a library/CLI without the file present
// console.error(`WARNING: Failed to read roster file (${ROSTER_FILE}): ${err.message}`);
}
cachedCanonicalRoster = FALLBACK_ROSTER;
@@ -206,7 +217,7 @@ function filterActiveLocks(locks) {
return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now);
}
async function queryLocks(relays, cadence, dateStr, namespace) {
export async function queryLocks(relays, cadence, dateStr, namespace) {
const pool = new SimplePool();
const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`;
const queryTimeoutMs = getQueryTimeoutMs();
@@ -228,7 +239,7 @@ async function queryLocks(relays, cadence, dateStr, namespace) {
}
}
async function publishLock(relays, event) {
export async function publishLock(relays, event) {
const pool = new SimplePool();
try {
@@ -255,7 +266,7 @@ async function publishLock(relays, event) {
}
}
async function cmdCheck(cadence) {
export async function cmdCheck(cadence) {
const relays = getRelays();
const namespace = getNamespace();
const dateStr = todayDateStr();
@@ -276,9 +287,7 @@ async function cmdCheck(cadence) {
const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent));
const available = roster.filter((a) => !excludedAgents.includes(a));
console.log(
JSON.stringify(
{
const result = {
namespace,
cadence,
date: dateStr,
@@ -295,14 +304,13 @@ async function cmdCheck(cadence) {
expiresAt: l.expiresAtIso,
platform: l.platform,
})),
},
null,
2,
),
);
};
console.log(JSON.stringify(result, null, 2));
return result;
}
async function cmdLock(agent, cadence, dryRun = false) {
export async function cmdLock(agent, cadence, dryRun = false) {
const relays = getRelays();
const namespace = getNamespace();
const dateStr = todayDateStr();
@@ -318,7 +326,7 @@ async function cmdLock(agent, cadence, dryRun = false) {
if (!roster.includes(agent)) {
console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
process.exit(1);
throw new ExitError(1, 'Agent not in roster');
}
console.error('Step 1: Checking for existing locks...');
@@ -334,7 +342,7 @@ async function cmdLock(agent, cadence, dryRun = false) {
console.log('LOCK_STATUS=denied');
console.log('LOCK_REASON=already_locked');
console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`);
process.exit(3);
throw new ExitError(3, 'Lock denied');
}
console.error('Step 2: Generating ephemeral keypair...');
@@ -393,7 +401,7 @@ async function cmdLock(agent, cadence, dryRun = false) {
console.log('LOCK_STATUS=race_lost');
console.log('LOCK_REASON=earlier_claim_exists');
console.log(`LOCK_WINNER_EVENT=${winner.eventId}`);
process.exit(3);
throw new ExitError(3, 'Race check lost');
}
console.error('RACE CHECK: won');
@@ -407,9 +415,10 @@ async function cmdLock(agent, cadence, dryRun = false) {
console.log(`LOCK_DATE=${dateStr}`);
console.log(`LOCK_EXPIRES=${expiresAt}`);
console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`);
return { status: 'ok', eventId: event.id };
}
async function cmdList(cadence) {
export async function cmdList(cadence) {
const relays = getRelays();
const namespace = getNamespace();
const dateStr = todayDateStr();
@@ -459,8 +468,84 @@ async function cmdList(cadence) {
}
}
export async function cmdDashboard(port = 4173) {
// Resolve package root relative to this file (src/lib.mjs)
// this file is in <root>/src/lib.mjs, so '..' goes to <root>
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const server = http.createServer((req, res) => {
// URL parsing
const url = new URL(req.url, `http://${req.headers.host}`);
let pathname = url.pathname;
// Redirect / to /dashboard/
if (pathname === '/' || pathname === '/dashboard') {
res.writeHead(302, { 'Location': '/dashboard/' });
res.end();
return;
}
// Special case: /torch-config.json
// Priority: User's CWD > Package default
if (pathname === '/torch-config.json') {
const userConfigPath = path.resolve(process.cwd(), 'torch-config.json');
if (fs.existsSync(userConfigPath)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
fs.createReadStream(userConfigPath).pipe(res);
return;
}
// If not found in CWD, fall through to serve from packageRoot (if it exists there)
// or return empty object if missing?
// Falling through means it looks for packageRoot/torch-config.json
}
// Security check: prevent directory traversal
const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
let filePath = path.join(packageRoot, safePath);
// If directory, try index.html
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
// Check if file exists and is a file
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
res.writeHead(404);
res.end('Not Found');
return;
}
// MIME types
const extname = path.extname(filePath);
let contentType = 'text/plain';
switch (extname) {
case '.html': contentType = 'text/html'; break;
case '.js': contentType = 'text/javascript'; break;
case '.css': contentType = 'text/css'; break;
case '.json': contentType = 'application/json'; break;
case '.png': contentType = 'image/png'; break;
case '.jpg': contentType = 'image/jpeg'; break;
case '.svg': contentType = 'image/svg+xml'; break;
case '.ico': contentType = 'image/x-icon'; break;
case '.md': contentType = 'text/markdown'; break;
}
res.writeHead(200, { 'Content-Type': contentType });
fs.createReadStream(filePath).pipe(res);
});
server.listen(port, () => {
console.log(`Dashboard running at http://localhost:${port}/dashboard/`);
console.log(`Serving files from ${packageRoot}`);
console.log(`Using configuration from ${process.cwd()}`);
});
// Keep process alive
return new Promise(() => {});
}
function parseArgs(argv) {
const args = { command: null, agent: null, cadence: null, dryRun: false };
const args = { command: null, agent: null, cadence: null, dryRun: false, port: 4173 };
let i = 0;
if (argv.length > 0 && !argv[0].startsWith('-')) {
@@ -480,6 +565,8 @@ function parseArgs(argv) {
args.cadence = arg.split('=')[1];
} else if (arg === '--dry-run') {
args.dryRun = true;
} else if (arg === '--port') {
args.port = parseInt(argv[++i], 10) || 4173;
}
}
@@ -487,12 +574,13 @@ function parseArgs(argv) {
}
function usage() {
console.error(`Usage: node src/nostr-lock.mjs <command> [options]
console.error(`Usage: torch-lock <command> [options]
Commands:
check --cadence <daily|weekly> Check locked agents (JSON)
lock --agent <name> --cadence <daily|weekly> Claim a lock
list [--cadence <daily|weekly>] Print active lock table
dashboard [--port <port>] Serve the dashboard (default: 4173)
Options:
--dry-run Build and sign the event but do not publish
@@ -514,19 +602,20 @@ Exit codes:
3 Lock denied (already locked or race lost)`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
export async function main(argv) {
try {
const args = parseArgs(argv);
if (!args.command) {
usage();
process.exit(1);
throw new ExitError(1, 'No command specified');
}
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);
throw new ExitError(1, 'Missing cadence');
}
await cmdCheck(args.cadence);
break;
@@ -535,11 +624,11 @@ async function main() {
case 'lock': {
if (!args.agent) {
console.error('ERROR: --agent <name> is required for lock');
process.exit(1);
throw new ExitError(1, 'Missing agent');
}
if (!args.cadence || !VALID_CADENCES.has(args.cadence)) {
console.error('ERROR: --cadence <daily|weekly> is required for lock');
process.exit(1);
throw new ExitError(1, 'Missing cadence');
}
await cmdLock(args.agent, args.cadence, args.dryRun);
break;
@@ -548,20 +637,28 @@ async function main() {
case 'list': {
if (args.cadence && !VALID_CADENCES.has(args.cadence)) {
console.error('ERROR: --cadence must be daily or weekly');
process.exit(1);
throw new ExitError(1, 'Invalid cadence');
}
await cmdList(args.cadence || null);
break;
}
case 'dashboard': {
await cmdDashboard(args.port);
break;
}
default:
console.error(`ERROR: Unknown command: ${args.command}`);
usage();
process.exit(1);
throw new ExitError(1, 'Unknown command');
}
} catch (err) {
if (err instanceof ExitError) {
process.exit(err.code);
} else {
console.error(`torch-lock failed: ${err.message}`);
process.exit(2);
}
}
}
main().catch((err) => {
console.error(`nostr-lock failed: ${err.message}`);
process.exit(2);
});
+3 -3
View File
@@ -5,13 +5,13 @@ import fs from 'node:fs';
describe('CLI Smoke Test', () => {
it('should print usage when no args provided', () => {
const result = spawnSync('node', ['src/nostr-lock.mjs']);
const result = spawnSync('node', ['bin/torch-lock.mjs']);
assert.strictEqual(result.status, 1);
assert.match(result.stderr.toString(), /Usage:/);
});
it('should fail when checking without cadence', () => {
const result = spawnSync('node', ['src/nostr-lock.mjs', 'check']);
const result = spawnSync('node', ['bin/torch-lock.mjs', 'check']);
assert.strictEqual(result.status, 1);
assert.match(result.stderr.toString(), /--cadence <daily|weekly> is required/);
});
@@ -23,7 +23,7 @@ describe('CLI Smoke Test', () => {
}));
try {
const result = spawnSync('node', ['src/nostr-lock.mjs', 'check', '--cadence', 'daily'], {
const result = spawnSync('node', ['bin/torch-lock.mjs', 'check', '--cadence', 'daily'], {
env: { ...process.env, TORCH_CONFIG_PATH: configPath }
});
const output = JSON.parse(result.stdout.toString());
+22
View File
@@ -0,0 +1,22 @@
{
"nostrLock": {
"namespace": "torch-example",
"relays": [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net"
],
"ttlSeconds": 7200,
"queryTimeoutMs": 15000
},
"dashboard": {
"defaultCadenceView": "daily",
"defaultStatusView": "active"
},
"scheduler": {
"firstPromptByCadence": {
"daily": "scheduler-update-agent",
"weekly": "bug-reproducer-agent"
}
}
}