mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
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:
1 parent
470093d9ad
commit
64b6f43df1
8 files changed
+606
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user