diff --git a/README.md b/README.md index c8870b0..cc80532 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,27 @@ From the repository root: ```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 ``` +### Project configuration (`torch-config.json`) + +Create a `torch-config.json` file in your project root (or set `TORCH_CONFIG_PATH`) to override defaults per repo: + +- `nostrLock.namespace` (d-tag/tag namespace) +- `nostrLock.relays` +- `nostrLock.ttlSeconds` +- `nostrLock.queryTimeoutMs` +- `nostrLock.dailyRoster` / `nostrLock.weeklyRoster` +- `dashboard.defaultCadenceView` (`daily`, `weekly`, or `all`) +- `dashboard.defaultStatusView` (`active` or `all`) +- `scheduler.firstPromptByCadence.daily` / `.weekly` + +Default first-run behavior is configured so daily scheduling starts with `scheduler-update-agent`. + Optional dashboard: ```bash @@ -48,8 +64,10 @@ Declared in `package.json` and pinned: - `NOSTR_LOCK_NAMESPACE` - `NOSTR_LOCK_RELAYS` - `NOSTR_LOCK_TTL` +- `NOSTR_LOCK_QUERY_TIMEOUT_MS` - `NOSTR_LOCK_DAILY_ROSTER` - `NOSTR_LOCK_WEEKLY_ROSTER` +- `TORCH_CONFIG_PATH` - `AGENT_PLATFORM` ## Roster precedence @@ -57,8 +75,9 @@ Declared in `package.json` and pinned: The lock CLI resolves roster names in this order: 1. `NOSTR_LOCK_DAILY_ROSTER` / `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated env overrides). -2. `src/prompts/roster.json` (`daily` / `weekly` canonical scheduler roster). -3. Built-in fallback roster (used only if `src/prompts/roster.json` is unreadable). +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). `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. diff --git a/dashboard/index.html b/dashboard/index.html index b6b230c..4e4b278 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -157,6 +157,8 @@ const DASHBOARD_DEFAULTS = { namespace: 'torch', hashtag: '', + defaultCadenceView: 'daily', + defaultStatusView: 'active', relays: [ 'wss://relay.damus.io', 'wss://nos.lol', @@ -164,13 +166,47 @@ ], }; - function parseDashboardConfig() { + function normalizeCadence(value) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'daily' || normalized === 'weekly' || normalized === 'all' + ? normalized + : DASHBOARD_DEFAULTS.defaultCadenceView; + } + + function normalizeStatus(value) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'active' || normalized === 'all' + ? normalized + : DASHBOARD_DEFAULTS.defaultStatusView; + } + + async function loadTorchConfigFile() { + const configCandidates = ['../torch-config.json', './torch-config.json']; + + for (const path of configCandidates) { + try { + const response = await fetch(path, { cache: 'no-store' }); + if (!response.ok) continue; + return await response.json(); + } catch { + // optional config, ignore + } + } + + return {}; + } + + function parseDashboardConfig(torchConfig = {}) { const params = new URLSearchParams(window.location.search); + const dashboardConfig = torchConfig.dashboard || {}; + const lockConfig = torchConfig.nostrLock || {}; + + const configuredNamespace = + (dashboardConfig.namespace || lockConfig.namespace || DASHBOARD_DEFAULTS.namespace); const namespace = - (params.get('namespace') || DASHBOARD_DEFAULTS.namespace).trim() || - DASHBOARD_DEFAULTS.namespace; + (params.get('namespace') || configuredNamespace).trim() || DASHBOARD_DEFAULTS.namespace; const hashtag = - (params.get('hashtag') || DASHBOARD_DEFAULTS.hashtag).trim() || + (params.get('hashtag') || dashboardConfig.hashtag || DASHBOARD_DEFAULTS.hashtag).trim() || `${namespace}-agent-lock`; const relaysParam = (params.get('relays') || '').trim(); const relays = relaysParam @@ -178,22 +214,26 @@ .split(',') .map((relay) => relay.trim()) .filter(Boolean) - : [...DASHBOARD_DEFAULTS.relays]; + : [...(dashboardConfig.relays || lockConfig.relays || DASHBOARD_DEFAULTS.relays)]; + const defaultCadenceView = normalizeCadence(dashboardConfig.defaultCadenceView); + const defaultStatusView = normalizeStatus(dashboardConfig.defaultStatusView); return { namespace, hashtag, relays, + defaultCadenceView, + defaultStatusView, }; } - const DASHBOARD_CONFIG = parseDashboardConfig(); + let DASHBOARD_CONFIG = parseDashboardConfig({}); - const RELAYS = DASHBOARD_CONFIG.relays; - const D_TAG_PREFIX = `${DASHBOARD_CONFIG.namespace}-lock/`; + let RELAYS = DASHBOARD_CONFIG.relays; + let D_TAG_PREFIX = `${DASHBOARD_CONFIG.namespace}-lock/`; const LOCK_EVENT_KIND = 30078; - const HASHTAG = DASHBOARD_CONFIG.hashtag; + let HASHTAG = DASHBOARD_CONFIG.hashtag; function applyConfigToHelpText() { const byId = (id) => document.getElementById(id); @@ -646,6 +686,9 @@ function init() { if (!isAlive()) return; + cadenceFilter.value = DASHBOARD_CONFIG.defaultCadenceView; + statusFilter.value = DASHBOARD_CONFIG.defaultStatusView; + applyConfigToHelpText(); for (const url of RELAYS) { @@ -653,7 +696,16 @@ } } - init(); + async function bootstrap() { + const torchConfig = await loadTorchConfigFile(); + DASHBOARD_CONFIG = parseDashboardConfig(torchConfig); + RELAYS = DASHBOARD_CONFIG.relays; + D_TAG_PREFIX = `${DASHBOARD_CONFIG.namespace}-lock/`; + HASHTAG = DASHBOARD_CONFIG.hashtag; + init(); + } + + bootstrap(); diff --git a/src/docs/TORCH.md b/src/docs/TORCH.md index 7d16158..28b5616 100644 --- a/src/docs/TORCH.md +++ b/src/docs/TORCH.md @@ -31,17 +31,37 @@ node src/nostr-lock.mjs list - `NOSTR_LOCK_NAMESPACE` - `NOSTR_LOCK_RELAYS` - `NOSTR_LOCK_TTL` +- `NOSTR_LOCK_QUERY_TIMEOUT_MS` - `NOSTR_LOCK_DAILY_ROSTER` - `NOSTR_LOCK_WEEKLY_ROSTER` +- `TORCH_CONFIG_PATH` - `AGENT_PLATFORM` +## torch-config.json + +You can configure TORCH per repository with a root-level `torch-config.json` file. + +Common settings: + +- `nostrLock.namespace` — namespace prefix used in d-tags and hashtags. +- `nostrLock.relays` — relay list for check/lock/list operations. +- `nostrLock.ttlSeconds` — default lock TTL. +- `nostrLock.queryTimeoutMs` — relay query timeout. +- `nostrLock.dailyRoster` / `nostrLock.weeklyRoster` — optional per-project roster overrides. +- `dashboard.defaultCadenceView` — default dashboard view (`daily`, `weekly`, `all`). +- `dashboard.defaultStatusView` — default dashboard status filter (`active`, `all`). +- `scheduler.firstPromptByCadence.daily` / `.weekly` — first-run scheduler starting agent. + +Default first-run daily scheduler prompt is `scheduler-update-agent`. + ## Roster precedence The lock CLI resolves roster names in this order: 1. `NOSTR_LOCK_DAILY_ROSTER` / `NOSTR_LOCK_WEEKLY_ROSTER` (comma-separated env overrides). -2. `src/prompts/roster.json` (`daily` / `weekly` canonical scheduler roster). -3. Built-in fallback roster (used only if `src/prompts/roster.json` is unreadable). +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). `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. diff --git a/src/nostr-lock.mjs b/src/nostr-lock.mjs index e90ddb8..049581d 100644 --- a/src/nostr-lock.mjs +++ b/src/nostr-lock.mjs @@ -10,6 +10,7 @@ import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure import { SimplePool } from 'nostr-tools/pool'; import { useWebSocketImplementation } from 'nostr-tools/relay'; import WebSocket from 'ws'; +import { loadTorchConfig } from './torch-config.mjs'; useWebSocketImplementation(WebSocket); @@ -21,7 +22,7 @@ const DEFAULT_RELAYS = [ const DEFAULT_TTL = 7200; const DEFAULT_NAMESPACE = 'torch'; -const QUERY_TIMEOUT_MS = 15_000; +const DEFAULT_QUERY_TIMEOUT_MS = 15_000; const VALID_CADENCES = new Set(['daily', 'weekly']); const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json'); @@ -72,27 +73,46 @@ const FALLBACK_ROSTER = { let cachedCanonicalRoster = null; function getRelays() { + const config = loadTorchConfig(); const envRelays = process.env.NOSTR_LOCK_RELAYS; if (envRelays) { return envRelays.split(',').map((r) => r.trim()).filter(Boolean); } + if (config.nostrLock.relays?.length) { + return config.nostrLock.relays; + } return DEFAULT_RELAYS; } function getNamespace() { - const namespace = (process.env.NOSTR_LOCK_NAMESPACE || DEFAULT_NAMESPACE).trim(); + const config = loadTorchConfig(); + const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim(); return namespace || DEFAULT_NAMESPACE; } function getTtl() { + const config = loadTorchConfig(); const envTtl = process.env.NOSTR_LOCK_TTL; if (envTtl) { const parsed = parseInt(envTtl, 10); if (!Number.isNaN(parsed) && parsed > 0) return parsed; } + if (config.nostrLock.ttlSeconds) { + return config.nostrLock.ttlSeconds; + } return DEFAULT_TTL; } +function getQueryTimeoutMs() { + const config = loadTorchConfig(); + const envValue = process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS; + if (envValue) { + const parsed = parseInt(envValue, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + return config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS; +} + function loadCanonicalRoster() { if (cachedCanonicalRoster) return cachedCanonicalRoster; @@ -127,15 +147,22 @@ function parseEnvRoster(value) { } function getRoster(cadence) { + const config = loadTorchConfig(); const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER); const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER); const canonical = loadCanonicalRoster(); + const dailyFromConfig = config.nostrLock.dailyRoster; + const weeklyFromConfig = config.nostrLock.weeklyRoster; if (cadence === 'daily') { - return dailyFromEnv && dailyFromEnv.length ? dailyFromEnv : canonical.daily; + if (dailyFromEnv && dailyFromEnv.length) return dailyFromEnv; + if (dailyFromConfig && dailyFromConfig.length) return dailyFromConfig; + return canonical.daily; } - return weeklyFromEnv && weeklyFromEnv.length ? weeklyFromEnv : canonical.weekly; + if (weeklyFromEnv && weeklyFromEnv.length) return weeklyFromEnv; + if (weeklyFromConfig && weeklyFromConfig.length) return weeklyFromConfig; + return canonical.weekly; } function todayDateStr() { @@ -182,6 +209,7 @@ function filterActiveLocks(locks) { async function queryLocks(relays, cadence, dateStr, namespace) { const pool = new SimplePool(); const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`; + const queryTimeoutMs = getQueryTimeoutMs(); try { const events = await Promise.race([ @@ -190,7 +218,7 @@ async function queryLocks(relays, cadence, dateStr, namespace) { '#t': [tagFilter], }), new Promise((_, reject) => - setTimeout(() => reject(new Error('Relay query timed out')), QUERY_TIMEOUT_MS), + setTimeout(() => reject(new Error('Relay query timed out')), queryTimeoutMs), ), ]); @@ -464,8 +492,10 @@ Environment: NOSTR_LOCK_NAMESPACE Namespace prefix for lock tags (default: torch) NOSTR_LOCK_RELAYS Comma-separated relay WSS URLs NOSTR_LOCK_TTL Lock TTL in seconds (default: 7200) + NOSTR_LOCK_QUERY_TIMEOUT_MS Relay query timeout in milliseconds (default: 15000) NOSTR_LOCK_DAILY_ROSTER Comma-separated daily roster (optional) NOSTR_LOCK_WEEKLY_ROSTER Comma-separated weekly roster (optional) + TORCH_CONFIG_PATH Optional path to torch-config.json (default: ./torch-config.json) AGENT_PLATFORM Platform identifier (e.g., codex) Exit codes: diff --git a/src/prompts/META_PROMPTS.md b/src/prompts/META_PROMPTS.md index 3ccbf73..dba3d3a 100644 --- a/src/prompts/META_PROMPTS.md +++ b/src/prompts/META_PROMPTS.md @@ -33,7 +33,10 @@ MUST 3: Run these commands in this order: - Derive `previous_agent` from that file with precedence: a) YAML frontmatter key `agent`. b) Filename format `____.md`. - - If no valid previous log exists (missing file, parse failure, or agent not in roster), set `start_index = 0`. + - If no valid previous log exists (missing file, parse failure, or agent not in roster): + - Read `scheduler.firstPromptByCadence.daily` from `torch-config.json` if present. + - If that agent is in roster, start there. + - Otherwise set `start_index = 0`. - Else set `start_index = (index(previous_agent)+1) mod roster_length`. - Round-robin from `start_index`, skipping excluded agents and wrapping with modulo until one eligible agent is found. - If none are eligible, write `_failed.md` with reason `All roster tasks currently claimed by other agents` and stop. @@ -81,7 +84,10 @@ MUST 3: Run these commands in this order: - Derive `previous_agent` from that file with precedence: a) YAML frontmatter key `agent`. b) Filename format `____.md`. - - If no valid previous log exists (missing file, parse failure, or agent not in roster), set `start_index = 0`. + - If no valid previous log exists (missing file, parse failure, or agent not in roster): + - Read `scheduler.firstPromptByCadence.weekly` from `torch-config.json` if present. + - If that agent is in roster, start there. + - Otherwise set `start_index = 0`. - Else set `start_index = (index(previous_agent)+1) mod roster_length`. - Round-robin from `start_index`, skipping excluded agents and wrapping with modulo until one eligible agent is found. - If none are eligible, write `_failed.md` with reason `All roster tasks currently claimed by other agents` and stop. diff --git a/src/prompts/scheduler-flow.md b/src/prompts/scheduler-flow.md index 511b623..a226932 100644 --- a/src/prompts/scheduler-flow.md +++ b/src/prompts/scheduler-flow.md @@ -43,7 +43,10 @@ Use this document for all scheduler runs. 1. Parse YAML frontmatter from `/` and use key `agent` when present and non-empty. 2. Otherwise parse filename convention `____.md` and take ``. - If no valid `latest_file` exists, or parsing fails, or `previous_agent` is not in `roster`, treat as first run fallback. - - First run fallback: `start_index = 0`. + - First run fallback: + - Read `scheduler.firstPromptByCadence.` from `torch-config.json` if present. + - If that agent exists in `roster`, set `start_index = index(configured_agent)`. + - Otherwise set `start_index = 0`. - Otherwise: `start_index = (index(previous_agent in roster) + 1) mod len(roster)`. - Round-robin scan: - Iterate offsets `0..len(roster)-1`. diff --git a/src/torch-config.mjs b/src/torch-config.mjs new file mode 100644 index 0000000..5d0ead4 --- /dev/null +++ b/src/torch-config.mjs @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_CONFIG_PATH = 'torch-config.json'; + +let cachedConfig = null; + +function normalizeCadence(value, fallback) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'daily' || normalized === 'weekly' || normalized === 'all' ? normalized : fallback; +} + +function normalizeStatus(value, fallback) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'active' || normalized === 'all' ? normalized : fallback; +} + +function parseRoster(value) { + if (!Array.isArray(value)) return null; + const parsed = value.map((item) => String(item).trim()).filter(Boolean); + return parsed.length > 0 ? parsed : null; +} + +export function getTorchConfigPath() { + const explicitPath = (process.env.TORCH_CONFIG_PATH || '').trim(); + if (explicitPath) return path.resolve(process.cwd(), explicitPath); + return path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); +} + +export function loadTorchConfig() { + if (cachedConfig) return cachedConfig; + + const configPath = getTorchConfigPath(); + let raw = {}; + + if (fs.existsSync(configPath)) { + try { + raw = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch (err) { + throw new Error(`Failed to parse ${configPath}: ${err.message}`); + } + } + + const nostrLock = raw.nostrLock || {}; + const dashboard = raw.dashboard || {}; + const scheduler = raw.scheduler || {}; + const firstPromptByCadence = scheduler.firstPromptByCadence || {}; + + cachedConfig = { + configPath, + raw, + nostrLock: { + namespace: typeof nostrLock.namespace === 'string' ? nostrLock.namespace.trim() : null, + relays: Array.isArray(nostrLock.relays) + ? nostrLock.relays.map((relay) => String(relay).trim()).filter(Boolean) + : null, + ttlSeconds: Number.isFinite(nostrLock.ttlSeconds) && nostrLock.ttlSeconds > 0 + ? Math.floor(nostrLock.ttlSeconds) + : null, + queryTimeoutMs: Number.isFinite(nostrLock.queryTimeoutMs) && nostrLock.queryTimeoutMs > 0 + ? Math.floor(nostrLock.queryTimeoutMs) + : null, + dailyRoster: parseRoster(nostrLock.dailyRoster), + weeklyRoster: parseRoster(nostrLock.weeklyRoster), + }, + dashboard: { + defaultCadenceView: normalizeCadence(dashboard.defaultCadenceView, 'daily'), + defaultStatusView: normalizeStatus(dashboard.defaultStatusView, 'active'), + relays: Array.isArray(dashboard.relays) + ? dashboard.relays.map((relay) => String(relay).trim()).filter(Boolean) + : null, + namespace: typeof dashboard.namespace === 'string' ? dashboard.namespace.trim() : null, + hashtag: typeof dashboard.hashtag === 'string' ? dashboard.hashtag.trim() : null, + }, + scheduler: { + firstPromptByCadence: { + daily: typeof firstPromptByCadence.daily === 'string' ? firstPromptByCadence.daily.trim() : null, + weekly: typeof firstPromptByCadence.weekly === 'string' ? firstPromptByCadence.weekly.trim() : null, + }, + }, + }; + + return cachedConfig; +} diff --git a/torch-config.json b/torch-config.json new file mode 100644 index 0000000..0ac71af --- /dev/null +++ b/torch-config.json @@ -0,0 +1,22 @@ +{ + "nostrLock": { + "namespace": "torch", + "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" + } + } +}