diff --git a/scripts/governance/process-proposals.mjs b/scripts/governance/process-proposals.mjs new file mode 100644 index 0000000..17b767c --- /dev/null +++ b/scripts/governance/process-proposals.mjs @@ -0,0 +1,45 @@ +import { listProposals, applyProposal, rejectProposal, validateProposal } from '../../src/services/governance/index.js'; + +async function main() { + console.log('Governance: Scanning for pending proposals...'); + const proposals = await listProposals(); + const pending = proposals.filter(p => p.status === 'pending'); + + if (pending.length === 0) { + console.log('No pending proposals found.'); + return; + } + + console.log(`Found ${pending.length} pending proposals.`); + + for (const p of pending) { + console.log(`\nProcessing proposal: ${p.id} (Target: ${p.target})`); + + // 1. Validate + const validation = await validateProposal(p.id); + if (!validation.valid) { + console.error(` Validation FAILED: ${validation.reason}`); + await rejectProposal(p.id, validation.reason); + console.log(` Rejected proposal ${p.id}.`); + continue; + } + console.log(' Validation PASSED.'); + + // 2. Apply + try { + await applyProposal(p.id); + console.log(` Applied proposal ${p.id} successfully.`); + } catch (e) { + console.error(` Application FAILED: ${e.message}`); + // Maybe reject with application error? + await rejectProposal(p.id, `Application failed: ${e.message}`); + } + } + + console.log('\nGovernance cycle complete.'); +} + +main().catch(err => { + console.error('Fatal error in governance script:', err); + process.exit(1); +}); diff --git a/src/cli-parser.mjs b/src/cli-parser.mjs index 21e20b5..b3cc2fa 100644 --- a/src/cli-parser.mjs +++ b/src/cli-parser.mjs @@ -34,12 +34,23 @@ export function parseArgs(argv) { windowMinutes: null, platform: null, model: null, + subcommand: null, + target: null, + content: null, + reason: null, + strategy: null, + status: null, }; let i = 0; if (argv.length > 0 && !argv[0].startsWith('-')) { args.command = argv[0]; i = 1; + // Support subcommands for 'proposal' + if (args.command === 'proposal' && argv.length > 1 && !argv[1].startsWith('-')) { + args.subcommand = argv[1]; + i = 2; + } } for (; i < argv.length; i++) { @@ -130,6 +141,26 @@ export function parseArgs(argv) { args.model = argv[++i]; } else if (arg.startsWith('--model=')) { args.model = arg.split('=')[1]; + } else if (arg === '--target') { + args.target = argv[++i]; + } else if (arg.startsWith('--target=')) { + args.target = arg.split('=')[1]; + } else if (arg === '--content') { + args.content = argv[++i]; + } else if (arg.startsWith('--content=')) { + args.content = arg.split('=')[1]; + } else if (arg === '--reason') { + args.reason = argv[++i]; + } else if (arg.startsWith('--reason=')) { + args.reason = arg.split('=')[1]; + } else if (arg === '--strategy') { + args.strategy = argv[++i]; + } else if (arg.startsWith('--strategy=')) { + args.strategy = arg.split('=')[1]; + } else if (arg === '--status') { + args.status = argv[++i]; + } else if (arg.startsWith('--status=')) { + args.status = arg.split('=')[1]; } } diff --git a/src/cmd-proposal.mjs b/src/cmd-proposal.mjs new file mode 100644 index 0000000..b098c9f --- /dev/null +++ b/src/cmd-proposal.mjs @@ -0,0 +1,100 @@ +import { createProposal, listProposals, applyProposal, rejectProposal, getProposal } from './services/governance/index.js'; +import { ExitError } from './errors.mjs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export async function cmdProposal(subcommand, args = {}) { + switch (subcommand) { + case 'create': + return await handleCreate(args); + case 'list': + return await handleList(args); + case 'apply': + return await handleApply(args); + case 'reject': + return await handleReject(args); + case 'show': + return await handleShow(args); + default: + console.error(`Unknown proposal subcommand: ${subcommand}`); + throw new ExitError(1, 'Unknown subcommand'); + } +} + +async function handleCreate({ agent, target, contentFile, reason }) { + if (!agent || !target || !contentFile || !reason) { + console.error('Usage: torch-lock proposal create --agent --target --content --reason '); + throw new ExitError(1, 'Missing arguments'); + } + + let newContent; + try { + newContent = await fs.readFile(contentFile, 'utf8'); + } catch (e) { + console.error(`Failed to read content file: ${contentFile}`); + throw new ExitError(1, 'File read error'); + } + + try { + const result = await createProposal({ agent, target, newContent, reason }); + console.log(JSON.stringify(result, null, 2)); + } catch (e) { + console.error(`Failed to create proposal: ${e.message}`); + throw new ExitError(1, 'Proposal creation failed'); + } +} + +async function handleList({ status }) { + try { + const proposals = await listProposals(); + const filtered = status ? proposals.filter(p => p.status === status) : proposals; + console.log(JSON.stringify(filtered, null, 2)); + } catch (e) { + console.error(`Failed to list proposals: ${e.message}`); + throw new ExitError(1, 'List failed'); + } +} + +async function handleApply({ id }) { + if (!id) { + console.error('Usage: torch-lock proposal apply --id '); + throw new ExitError(1, 'Missing id'); + } + + try { + const result = await applyProposal(id); + console.log(JSON.stringify(result, null, 2)); + } catch (e) { + console.error(`Failed to apply proposal: ${e.message}`); + throw new ExitError(1, 'Apply failed'); + } +} + +async function handleReject({ id, reason }) { + if (!id || !reason) { + console.error('Usage: torch-lock proposal reject --id --reason '); + throw new ExitError(1, 'Missing arguments'); + } + + try { + const result = await rejectProposal(id, reason); + console.log(JSON.stringify(result, null, 2)); + } catch (e) { + console.error(`Failed to reject proposal: ${e.message}`); + throw new ExitError(1, 'Reject failed'); + } +} + +async function handleShow({ id }) { + if (!id) { + console.error('Usage: torch-lock proposal show --id '); + throw new ExitError(1, 'Missing id'); + } + try { + const proposal = await getProposal(id); + console.log(JSON.stringify(proposal, null, 2)); + } catch (e) { + console.error(`Failed to show proposal: ${e.message}`); + throw new ExitError(1, 'Show failed'); + } +} diff --git a/src/cmd-rollback.mjs b/src/cmd-rollback.mjs new file mode 100644 index 0000000..1af450a --- /dev/null +++ b/src/cmd-rollback.mjs @@ -0,0 +1,19 @@ +import { rollbackPrompt } from './services/governance/index.js'; +import { ExitError } from './errors.mjs'; + +export async function cmdRollback(target, strategy) { + if (!target) { + console.error('Usage: torch-lock rollback --target [--strategy ]'); + throw new ExitError(1, 'Missing target'); + } + + const effectiveStrategy = strategy || 'latest'; + + try { + const result = await rollbackPrompt(target, effectiveStrategy); + console.log(JSON.stringify(result, null, 2)); + } catch (e) { + console.error(`Rollback failed: ${e.message}`); + throw new ExitError(1, 'Rollback failed'); + } +} diff --git a/src/lib.mjs b/src/lib.mjs index ec97d94..1330522 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -37,7 +37,11 @@ import { todayDateStr, nowUnix, detectPlatform } from './utils.mjs'; import { runRelayHealthCheck } from './relay-health.mjs'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { execSync } from 'node:child_process'; import { getCompletedAgents } from './lock-utils.mjs'; +import { cmdProposal } from './cmd-proposal.mjs'; +import { cmdRollback } from './cmd-rollback.mjs'; useWebSocketImplementation(WebSocket); @@ -188,10 +192,33 @@ export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {} const now = nowUnix(); const expiresAt = now + ttl; + let gitCommit = null; + try { + gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim(); + } catch { + // Ignore git errors (not a git repo, etc) + } + + let promptHash = null; + let promptPath = null; + try { + // Attempt to locate the prompt file based on standard structure + // We assume the CWD is the project root or we are in a standard structure. + // If not found, we just omit the hash. + const potentialPath = path.join(process.cwd(), 'src', 'prompts', cadence, `${agent}.md`); + const content = await fs.readFile(potentialPath, 'utf8'); + promptHash = createHash('sha256').update(content).digest('hex'); + promptPath = `src/prompts/${cadence}/${agent}.md`; + } catch { + // Ignore missing prompt file + } + error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); error(`Hashtag: #${hashtag}`); error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); error(`Relays: ${relays.join(', ')}`); + if (gitCommit) error(`Git Commit: ${gitCommit}`); + if (promptHash) error(`Prompt Hash: ${promptHash.slice(0, 12)}...`); const roster = await getRoster(cadence); if (!roster.includes(agent)) { @@ -252,6 +279,9 @@ export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {} model: model || process.env.AGENT_MODEL || 'unknown', lockedAt: new Date(now * 1000).toISOString(), expiresAt: new Date(expiresAt * 1000).toISOString(), + gitCommit, + promptPath, + promptHash, }), }, sk, @@ -297,6 +327,8 @@ export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {} log(`LOCK_DATE=${dateStr}`); log(`LOCK_EXPIRES=${expiresAt}`); log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); + if (gitCommit) log(`LOCK_GIT_COMMIT=${gitCommit}`); + if (promptHash) log(`LOCK_PROMPT_HASH=${promptHash}`); return { status: 'ok', eventId: event.id }; } @@ -668,6 +700,27 @@ export async function main(argv) { break; } + case 'proposal': { + if (!args.subcommand) { + console.error('ERROR: Missing subcommand for proposal (create, list, apply, reject, show)'); + throw new ExitError(1, 'Missing subcommand'); + } + await cmdProposal(args.subcommand, { + agent: args.agent, + target: args.target, + contentFile: args.content, + reason: args.reason, + id: args.id, + status: args.status + }); + break; + } + + case 'rollback': { + await cmdRollback(args.target, args.strategy); + break; + } + default: console.error(`ERROR: Unknown command: ${args.command}`); usage(); diff --git a/src/ops.mjs b/src/ops.mjs index d3d94ce..b529018 100644 --- a/src/ops.mjs +++ b/src/ops.mjs @@ -178,6 +178,18 @@ function ensureInstallDirectory(paths, force, installDir) { } ensureDir(paths.torchDir); ensureDir(paths.promptsDir); + + // Ensure governance directories + // We assume standard structure src/proposals and .torch/prompt-history + // even if installed in a subdirectory, these are usually repo-level. + // But if installed in 'torch', maybe they should be in 'torch/src/proposals'? + // For now, we follow the pattern that prompts are managed where the scheduler expects them. + // If we are in this repo, it's src/prompts. + // If torch is initializing a new repo, it might put prompts in installDir/prompts. + // However, governance service currently hardcodes 'src/proposals'. + // So we ensure 'src/proposals' relative to root. + ensureDir(path.join(paths.root, 'src', 'proposals')); + ensureDir(path.join(paths.root, '.torch', 'prompt-history')); } function installAppAssets(torchDir, installDir) { diff --git a/src/prompts/daily/governance-agent.md b/src/prompts/daily/governance-agent.md new file mode 100644 index 0000000..eeb1906 --- /dev/null +++ b/src/prompts/daily/governance-agent.md @@ -0,0 +1,60 @@ +> **Shared contract (required):** Follow [`Scheduler Flow → Shared Agent Run Contract`](../scheduler-flow.md#shared-agent-run-contract-required-for-all-spawned-agents) and [`Scheduler Flow → Canonical artifact paths`](../scheduler-flow.md#canonical-artifact-paths) before and during this run. + +## Required startup + artifacts + memory + issue capture + +- Baseline reads (required, before implementation): `AGENTS.md`, `CLAUDE.md`, `KNOWN_ISSUES.md`, and `docs/agent-handoffs/README.md`. +- Run artifacts (required): update or explicitly justify omission for `src/context/`, `src/todo/`, `src/decisions/`, and `src/test_logs/`. +- Unresolved issue handling (required): if unresolved/reproducible findings remain, update `KNOWN_ISSUES.md` and add or update an incidents note in `docs/agent-handoffs/incidents/`. +- Memory contract (required): execute configured memory retrieval before implementation and configured memory storage after implementation, preserving scheduler evidence markers/artifacts. +- Completion ownership (required): **do not** run `lock:complete` and **do not** create final `task-logs//____completed.md` or `__failed.md`; spawned agents hand results back to the scheduler, and the scheduler owns completion publishing/logging. + +You are: **governance-agent**, responsible for reviewing and applying prompt change proposals. + +Mission: Ensure that all prompt changes follow the established governance rules (stable constitution, allowed targets, required headers). + +─────────────────────────────────────────────────────────────────────────────── +AUTHORITY HIERARCHY (highest wins) + +1. `AGENTS.md` — repo-wide agent policy (overrides everything below) +2. `CLAUDE.md` — repo-specific guidance and conventions +3. Scheduler prompts (`src/prompts/daily-scheduler.md`, `src/prompts/weekly-scheduler.md`) +4. This prompt (your own instructions) + +Read both `AGENTS.md` and `CLAUDE.md` before executing any task. + +─────────────────────────────────────────────────────────────────────────────── +SCOPE + +In scope: + - `src/proposals/` — Reviewing proposals. + - `src/prompts/daily/` and `src/prompts/weekly/` — Applying changes. + - `scripts/governance/process-proposals.mjs` — Executing validation logic. + +Out of scope: + - Application code (unless related to governance infrastructure). + - Editing non-prompt files (unless necessary for governance). + +─────────────────────────────────────────────────────────────────────────────── +GOALS & SUCCESS CRITERIA + +1. Execute Governance Cycle — Run `node scripts/governance/process-proposals.mjs` to process pending proposals. +2. Verify Outcomes — Check logs and verify that valid proposals were applied and invalid ones rejected. +3. Document Actions — Log processed proposals in `src/decisions/DECISIONS_.md`. + +─────────────────────────────────────────────────────────────────────────────── +WORKFLOW + +1. Preflight + - Read `AGENTS.md` and `CLAUDE.md`. + - Update persistent state files (`src/context`, `src/todo`, `src/decisions`, `src/test_logs`). + +2. Execute Governance Script + - Run `node scripts/governance/process-proposals.mjs`. + - Capture output. + +3. Verify & Document + - If proposals were processed, document them in `src/decisions/DECISIONS_.md`. + - Update `src/test_logs/TEST_LOG_.md` with execution results. + +4. Completion + - Hand off to scheduler. diff --git a/src/services/governance/index.js b/src/services/governance/index.js new file mode 100644 index 0000000..c57029d --- /dev/null +++ b/src/services/governance/index.js @@ -0,0 +1,286 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; + +const PROPOSALS_DIR = path.resolve(process.cwd(), 'src/proposals'); +const HISTORY_DIR = path.resolve(process.cwd(), '.torch/prompt-history'); +const ALLOWED_TARGET_DIRS = [ + path.resolve(process.cwd(), 'src/prompts/daily'), + path.resolve(process.cwd(), 'src/prompts/weekly'), +]; + +async function ensureDirs() { + await fs.mkdir(PROPOSALS_DIR, { recursive: true }); + await fs.mkdir(HISTORY_DIR, { recursive: true }); +} + +export async function listProposals() { + await ensureDirs(); + const entries = await fs.readdir(PROPOSALS_DIR, { withFileTypes: true }); + const proposals = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const metaPath = path.join(PROPOSALS_DIR, entry.name, 'meta.json'); + try { + const metaContent = await fs.readFile(metaPath, 'utf8'); + const meta = JSON.parse(metaContent); + proposals.push({ + id: entry.name, + ...meta + }); + } catch (e) { + // Ignore invalid proposals + } + } + } + return proposals; +} + +export async function getProposal(id) { + const dir = path.join(PROPOSALS_DIR, id); + const metaPath = path.join(dir, 'meta.json'); + const newPath = path.join(dir, 'new.md'); + const diffPath = path.join(dir, 'change.diff'); + + try { + const meta = JSON.parse(await fs.readFile(metaPath, 'utf8')); + const newContent = await fs.readFile(newPath, 'utf8'); + let diff = ''; + try { + diff = await fs.readFile(diffPath, 'utf8'); + } catch {} + + return { + id, + meta, + newContent, + diff, + dir + }; + } catch (e) { + throw new Error(`Proposal ${id} not found or invalid: ${e.message}`); + } +} + +export async function createProposal({ agent, target, newContent, reason }) { + await ensureDirs(); + + // Validate target path is allowed + const absoluteTarget = path.resolve(process.cwd(), target); + const isAllowed = ALLOWED_TARGET_DIRS.some(allowed => absoluteTarget.startsWith(allowed)); + + if (!isAllowed) { + throw new Error(`Target ${target} is not in an allowed directory (src/prompts/daily or src/prompts/weekly).`); + } + + // Generate ID + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const id = `${timestamp}_${agent}`; + const dir = path.join(PROPOSALS_DIR, id); + + await fs.mkdir(dir, { recursive: true }); + + // Write content + await fs.writeFile(path.join(dir, 'new.md'), newContent); + + // Generate Diff if target exists + let diff = ''; + try { + // Check if target exists + await fs.access(absoluteTarget); + + // Create a temporary file for the diff logic to work cleanly + // But direct path is fine. + const newFile = path.join(dir, 'new.md'); + const diffCmd = `git diff --no-index --color=never "${absoluteTarget}" "${newFile}"`; + try { + execSync(diffCmd, { stdio: 'pipe' }); // Will throw if exit code 1 (diff found) + } catch (e) { + if (e.status === 1 && e.stdout) { + diff = e.stdout.toString(); + } else if (e.status !== 1) { + // Some other error + console.error('Diff generation failed:', e.message); + } + } + } catch (err) { + diff = '(New File)'; + } + + await fs.writeFile(path.join(dir, 'change.diff'), diff); + + const meta = { + id, + author: agent, + target, + reason, + timestamp: new Date().toISOString(), + status: 'pending' + }; + + await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2)); + + return { id, diff }; +} + +export async function validateProposal(id) { + const { meta, newContent } = await getProposal(id); + + // 1. Allowlist check (redundant but safe) + const absoluteTarget = path.resolve(process.cwd(), meta.target); + const isAllowed = ALLOWED_TARGET_DIRS.some(allowed => absoluteTarget.startsWith(allowed)); + if (!isAllowed) { + return { valid: false, reason: 'Target not in allowed directories.' }; + } + + // 2. Invariant checks + // Check for required headers based on common prompt structure + const requiredPatterns = [ + /Shared contract \(required\):/i, + /Required startup \+ artifacts \+ memory \+ issue capture/i, + ]; + + for (const pattern of requiredPatterns) { + if (!pattern.test(newContent)) { + return { valid: false, reason: `Missing required header pattern: ${pattern}` }; + } + } + + return { valid: true }; +} + +export async function applyProposal(id) { + const proposal = await getProposal(id); + const { meta, newContent, dir } = proposal; + + if (meta.status !== 'pending') { + throw new Error(`Proposal is ${meta.status}, cannot apply.`); + } + + const validation = await validateProposal(id); + if (!validation.valid) { + // Mark as rejected if validation fails? + // Or just throw? The governance agent should decide. + // We throw here to prevent accidental application. + throw new Error(`Validation failed: ${validation.reason}`); + } + + const absoluteTarget = path.resolve(process.cwd(), meta.target); + + // Archive old + try { + const oldContent = await fs.readFile(absoluteTarget, 'utf8'); + const hash = createHash('sha256').update(oldContent).digest('hex'); + + // We store archives in .torch/prompt-history//.md + // Note: meta.target is relative path like 'src/prompts/daily/agent.md' + // We want to keep that structure. + const archiveDir = path.join(HISTORY_DIR, path.dirname(meta.target)); + await fs.mkdir(archiveDir, { recursive: true }); + + // Add timestamp to filename for easier sorting later? + // Or just rely on hash and store metadata elsewhere? + // User requested /.md. + const archivePath = path.join(archiveDir, `${path.basename(meta.target, '.md')}_${hash}.md`); + await fs.writeFile(archivePath, oldContent); + } catch (e) { + // If file didn't exist, nothing to archive + } + + // Apply new + await fs.mkdir(path.dirname(absoluteTarget), { recursive: true }); + await fs.writeFile(absoluteTarget, newContent); + + // Update meta + meta.status = 'applied'; + meta.appliedAt = new Date().toISOString(); + await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2)); + + // Git Commit (optional) + try { + // We use execSync for git operations + // Ensure we are in repo root? cwd is repo root. + execSync(`git add "${meta.target}"`); + const commitMsg = `feat(prompts): apply proposal ${id} by ${meta.author}`; + execSync(`git commit -m "${commitMsg}"`); + meta.gitCommit = execSync('git rev-parse HEAD').toString().trim(); + await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2)); + } catch (gitErr) { + console.warn('Git commit failed (ignoring):', gitErr.message); + } + + return { success: true }; +} + +export async function rejectProposal(id, reason) { + const proposal = await getProposal(id); + const { meta, dir } = proposal; + + meta.status = 'rejected'; + meta.rejectionReason = reason; + meta.rejectedAt = new Date().toISOString(); + + await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta, null, 2)); + return { success: true }; +} + +export async function rollbackPrompt(target, hashOrStrategy = 'latest') { + // target is relative path e.g. src/prompts/daily/agent.md + const archiveDir = path.join(HISTORY_DIR, path.dirname(target)); + const absoluteTarget = path.resolve(process.cwd(), target); + + let sourceContent = null; + let sourceName = null; + + try { + // Try Local Archive first + if (hashOrStrategy === 'latest') { + // Find latest file in archiveDir + // Our naming is _.md. + // We can rely on file mtime. + const files = await fs.readdir(archiveDir); + const targetBase = path.basename(target, '.md'); + const prefix = targetBase + '_'; + const candidates = files.filter(f => f.startsWith(prefix)); + + if (candidates.length > 0) { + const stats = await Promise.all(candidates.map(async f => ({ + name: f, + ...await fs.stat(path.join(archiveDir, f)) + }))); + const latest = stats.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]; + sourceName = latest.name; + sourceContent = await fs.readFile(path.join(archiveDir, sourceName), 'utf8'); + } + } else { + // Try to find file containing hash + const files = await fs.readdir(archiveDir); + const match = files.find(f => f.includes(hashOrStrategy)); + if (match) { + sourceName = match; + sourceContent = await fs.readFile(path.join(archiveDir, match), 'utf8'); + } + } + } catch (e) { + // Archive lookup failed + } + + if (sourceContent) { + await fs.writeFile(absoluteTarget, sourceContent); + return { success: true, source: 'archive', restored: sourceName }; + } + + // Fallback to Git + try { + const commit = hashOrStrategy === 'latest' ? 'HEAD' : hashOrStrategy; + // Use git show instead of checkout to avoid detaching head if we just want content + // But user said "git checkout" implying revert state. + // Actually `git checkout -- ` overwrites the file in working tree. + execSync(`git checkout "${commit}" -- "${target}"`); + return { success: true, source: 'git', restored: commit }; + } catch (e) { + throw new Error(`Rollback failed: Local archive not found and git failed (${e.message})`); + } +}