feat: add governance system for prompt evolution

Implement a comprehensive governance system to manage agent prompt changes safely.

Key features:
- **Proposal Workflow:** Prompt changes are now "proposals" stored in `src/proposals/` with metadata and diffs.
- **Governance Agent:** A new `governance-agent` reviews and applies proposals using `scripts/governance/process-proposals.mjs`.
- **Validation:** Strict validation ensures changes are limited to allowed directories (`src/prompts/daily|weekly`) and preserve required headers.
- **Lock Enhancements:** Lock events now include `promptHash`, `promptPath`, and `gitCommit` for full auditability.
- **Rollback:** New `torch-lock rollback` command restores prompts from local history (`.torch/prompt-history/`) or git.
- **CLI:** Added `proposal` (create, list, show, apply, reject) and `rollback` commands.

This establishes a "Stage -> Evaluate -> Apply" gate for prompt evolution, preventing silent drift and ensuring constitutional stability.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-18 21:09:39 +00:00
1 parent 470093d9ad
commit 64b6f43df1
8 files changed
+606

No files matched your search

+45
View File
@@ -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);
});
+31
View File
@@ -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];
}
}
+100
View File
@@ -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 <name> --target <path> --content <file> --reason <text>');
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 <proposal-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 <proposal-id> --reason <text>');
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 <proposal-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');
}
}
+19
View File
@@ -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 <path> [--strategy <hash|latest>]');
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');
}
}
+53
View File
@@ -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();
+12
View File
@@ -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) {
+60
View File
@@ -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/<cadence>/<timestamp>__<agent-name>__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_<timestamp>.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_<timestamp>.md`.
- Update `src/test_logs/TEST_LOG_<timestamp>.md` with execution results.
4. Completion
- Hand off to scheduler.
+286
View File
@@ -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/<target_path>/<hash>.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 <path>/<hash>.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 <agent>_<hash>.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 <commit> -- <path>` 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})`);
}
}