From be5ec21d5c1c141e9f5f0a3f9bd8a7270e8cb652 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 02:27:57 +0000 Subject: [PATCH 1/3] feat(governance): add prompt versioning, rollback --list, and state backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - governance/index.js: archive filenames now include ISO timestamp for deterministic sorting (__.md); write .meta.json sidecar on every archive with proposalId, author, reason, and archivedAt - governance/index.js: add listPromptVersions(target) — returns archived versions for a prompt sorted newest-first with sidecar metadata surfaced - cmd-rollback.mjs: add --list flag to list available versions without restoring (delegates to listPromptVersions) - cli-parser.mjs: add --list and --output flags - lib.mjs: pass list flag to cmdRollback; add backup command dispatch - cmd-backup.mjs: new torch-lock backup command — snapshots .scheduler-memory/memory-store.json and scheduler run state to .torch/backups//; supports --output override and --list - docs/prompt-versioning.md: comprehensive guide to the full proposal → apply → list-versions → rollback workflow, state backup and restore procedure, and storage location reference - test/governance.test.mjs: 7 scenario-based tests (SCN-proposal-create, SCN-proposal-list, SCN-proposal-reject, SCN-archive-naming, SCN-version-list, SCN-rollback-latest, SCN-rollback-hash) https://claude.ai/code/session_01Ndq2D72Kp77BYo4owD63uY --- docs/prompt-versioning.md | 290 ++++++++++++++++++++++++ src/cli-parser.mjs | 8 + src/cmd-backup.mjs | 109 +++++++++ src/cmd-rollback.mjs | 17 +- src/lib.mjs | 13 +- src/services/governance/index.js | 150 +++++++++---- test/governance.test.mjs | 374 +++++++++++++++++++++++++++++++ 7 files changed, 914 insertions(+), 47 deletions(-) create mode 100644 docs/prompt-versioning.md create mode 100644 src/cmd-backup.mjs create mode 100644 test/governance.test.mjs diff --git a/docs/prompt-versioning.md b/docs/prompt-versioning.md new file mode 100644 index 0000000..eedfa0e --- /dev/null +++ b/docs/prompt-versioning.md @@ -0,0 +1,290 @@ +# Prompt Versioning and State Backup + +This document describes how TORCH manages agent prompt versions and runtime state backups — two distinct but complementary systems. + +--- + +## Overview + +| Concern | Mechanism | Storage location | +|---------|-----------|-----------------| +| Prompt change control | Governance proposals | `src/proposals/` | +| Prompt version archive | Applied-proposal archive | `.torch/prompt-history/` | +| Prompt rollback | `torch-lock rollback` | Reads `.torch/prompt-history/`, falls back to git | +| State snapshot | `torch-lock backup` | `.torch/backups//` | + +--- + +## 1. Prompt Versioning + +### How it works + +Every agent prompt lives in `src/prompts/daily/.md` or `src/prompts/weekly/.md`. These are the canonical sources of truth. + +When a change is proposed and applied through the governance system, TORCH automatically archives the **old** content before overwriting the file. The archive is stored in: + +``` +.torch/prompt-history//__.md +``` + +A sidecar metadata file is written alongside each archive: + +``` +.torch/prompt-history//__.meta.json +``` + +The sidecar records who made the change, why, and when — making the archive self-documenting. + +### Filename format + +``` +audit-agent_2026-01-15T00-00-00-000Z_a3f5b19c....md +│ │ │ +│ │ └─ SHA-256 of old content +│ └─ ISO timestamp (colons/dots replaced with dashes) +└─ Prompt base name +``` + +This naming scheme ensures versions sort lexicographically by time with no reliance on filesystem mtime. + +--- + +## 2. Proposal Workflow (Create → Apply → Rollback) + +### Step 1 — Create a proposal + +```bash +torch-lock proposal create \ + --agent \ + --target src/prompts/daily/.md \ + --content /path/to/new-content.md \ + --reason "Add missing EXIT CRITERIA section" +``` + +This writes the proposal to `src/proposals/_/`: +- `meta.json` — author, target, reason, status +- `new.md` — proposed new content +- `change.diff` — git diff against current file + +The target path must be within `src/prompts/daily/` or `src/prompts/weekly/`. + +### Step 2 — List pending proposals + +```bash +torch-lock proposal list +# or filter by status: +torch-lock proposal list --status pending +``` + +### Step 3 — Inspect a proposal + +```bash +torch-lock proposal show --id +``` + +### Step 4 — Apply the proposal + +```bash +torch-lock proposal apply --id +``` + +Before writing the new content, TORCH: +1. Validates the proposal passes all invariant checks (required contract headers) +2. Archives the **current** file content to `.torch/prompt-history/` +3. Writes the new content to the target path +4. Attempts a `git add` + `git commit` automatically + +A proposal can only be applied when its status is `pending`. + +### Step 5 — Reject a proposal (optional alternative) + +```bash +torch-lock proposal reject --id --reason "Does not preserve EXIT CRITERIA pattern" +``` + +--- + +## 3. Listing Available Versions + +To see what archived versions exist for a prompt: + +```bash +torch-lock rollback --target src/prompts/daily/audit-agent.md --list +``` + +Output (JSON array, newest first): + +```json +[ + { + "filename": "audit-agent_2026-02-20T12-00-00-000Z_a3f5b19c.md", + "archivedAt": "2026-02-20T12:00:00.000Z", + "hash": "a3f5b19c...", + "proposalId": "2026-02-20T12-00-00-000Z_prompt-fixer-agent", + "author": "prompt-fixer-agent", + "reason": "Add missing EXIT CRITERIA section" + } +] +``` + +--- + +## 4. Rolling Back a Prompt + +### Restore the most recent version + +```bash +torch-lock rollback --target src/prompts/daily/audit-agent.md +# same as: +torch-lock rollback --target src/prompts/daily/audit-agent.md --strategy latest +``` + +### Restore a specific version by hash fragment + +```bash +torch-lock rollback --target src/prompts/daily/audit-agent.md --strategy a3f5b19c +``` + +Any unique fragment of the hash or timestamp in the filename works as a strategy value. + +### Fallback to git + +If no local archive exists, TORCH falls back to git: + +```bash +# Restores the HEAD version from git (useful if .torch/ was lost) +torch-lock rollback --target src/prompts/daily/audit-agent.md --strategy latest +``` + +Or specify an explicit git commit SHA: + +```bash +torch-lock rollback --target src/prompts/daily/audit-agent.md --strategy +``` + +The fallback uses `git checkout -- `, which overwrites the file in the working tree. + +--- + +## 5. State Backup + +Agent runtime state lives outside of git in two places: + +| File | Contents | +|------|----------| +| `.scheduler-memory/memory-store.json` | Agent long-term memory | +| `task-logs/daily/.scheduler-run-state.json` | Scheduler lock deferral state | + +### Create a snapshot + +```bash +torch-lock backup +``` + +This writes a timestamped directory under `.torch/backups/`: + +``` +.torch/backups/ +└── 2026-02-20T12-00-00-000Z/ + ├── backup-manifest.json + ├── .scheduler-memory__memory-store.json + └── task-logs__daily__.scheduler-run-state.json +``` + +`backup-manifest.json` records what was captured, what was skipped (if a file was missing), the git commit at backup time, and the ISO timestamp. + +### Write to a custom directory + +```bash +torch-lock backup --output /path/to/my-backup-dir +``` + +### List existing backups + +```bash +torch-lock backup --list +``` + +Output (JSON array, newest first): + +```json +[ + { + "id": "2026-02-20T12-00-00-000Z", + "createdAt": "2026-02-20T12:00:00.000Z", + "gitCommit": "a3f5b19...", + "captured": [ + ".scheduler-memory/memory-store.json", + "task-logs/daily/.scheduler-run-state.json" + ], + "backupDir": "/path/to/TORCH/.torch/backups/2026-02-20T12-00-00-000Z" + } +] +``` + +### Restore from a backup + +Restoration is a manual copy. From your project root: + +```bash +# Restore memory store +cp .torch/backups//.scheduler-memory__memory-store.json .scheduler-memory/memory-store.json + +# Restore scheduler run state +cp .torch/backups//task-logs__daily__.scheduler-run-state.json task-logs/daily/.scheduler-run-state.json +``` + +There is intentionally no automated restore command — restoring memory state is a significant operation that should be a deliberate human decision. + +### Recommended backup cadence + +The `backup-agent` (weekly) can be configured to run `torch-lock backup` as part of the weekly cycle. Alternatively, add it to your CI pipeline or cron schedule. Backups are cheap (two small JSON files) and idempotent. + +--- + +## 6. What Is and Is Not Versioned + +| Artifact | Version tracked | How | +|----------|----------------|-----| +| `src/prompts/daily/*.md` | Yes | Proposal archive + git history | +| `src/prompts/weekly/*.md` | Yes | Proposal archive + git history | +| `.scheduler-memory/memory-store.json` | Snapshot only | `torch-lock backup` | +| `task-logs/daily/.scheduler-run-state.json` | Snapshot only | `torch-lock backup` | +| `src/proposals/*/` | Permanent record | On-disk (not archived further) | +| `torch-config.json` | Git history | Standard git | + +--- + +## 7. Storage Locations Reference + +``` +.torch/ +├── prompt-history/ # Prompt archive (created on first proposal apply) +│ └── src/prompts/daily/ +│ ├── audit-agent__.md # Archived prompt content +│ └── audit-agent__.meta.json # Archive metadata +└── backups/ # State snapshots + └── / + ├── backup-manifest.json + ├── .scheduler-memory__memory-store.json + └── task-logs__daily__.scheduler-run-state.json + +src/proposals/ +└── _/ # One directory per proposal + ├── meta.json + ├── new.md + └── change.diff +``` + +--- + +## 8. Removing TORCH + +The `.torch/` directory (prompt history + backups) and `src/proposals/` are removed by `torch-lock remove`. See [docs/removal.md](removal.md) for the full removal procedure. + +If you want to preserve the prompt history or state backups before removing TORCH, copy `.torch/` to a safe location first: + +```bash +cp -r .torch/ ~/torch-archive-$(date +%Y%m%d)/ +torch-lock remove +``` diff --git a/src/cli-parser.mjs b/src/cli-parser.mjs index b3cc2fa..b39f870 100644 --- a/src/cli-parser.mjs +++ b/src/cli-parser.mjs @@ -40,6 +40,8 @@ export function parseArgs(argv) { reason: null, strategy: null, status: null, + list: false, + output: null, }; let i = 0; @@ -161,6 +163,12 @@ export function parseArgs(argv) { args.status = argv[++i]; } else if (arg.startsWith('--status=')) { args.status = arg.split('=')[1]; + } else if (arg === '--list') { + args.list = true; + } else if (arg === '--output') { + args.output = argv[++i]; + } else if (arg.startsWith('--output=')) { + args.output = arg.split('=')[1]; } } diff --git a/src/cmd-backup.mjs b/src/cmd-backup.mjs new file mode 100644 index 0000000..ee053b2 --- /dev/null +++ b/src/cmd-backup.mjs @@ -0,0 +1,109 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +const BACKUP_ROOT = path.resolve(process.cwd(), '.torch/backups'); + +// Paths to snapshot on each backup run +const STATE_SOURCES = [ + '.scheduler-memory/memory-store.json', + 'task-logs/daily/.scheduler-run-state.json', +]; + +/** + * Creates a timestamped snapshot of TORCH runtime state under .torch/backups//. + * + * Files captured (if they exist): + * - .scheduler-memory/memory-store.json (agent long-term memory) + * - task-logs/daily/.scheduler-run-state.json (scheduler deferral state) + * + * A backup-manifest.json is always written recording what was captured, + * the git commit at backup time, and an ISO timestamp. + * + * @param {Object} [opts] + * @param {string|null} [opts.output] - Override destination directory (default: .torch/backups/) + * @returns {Promise<{backupDir: string, manifest: Object}>} + */ +export async function cmdBackup({ output = null } = {}) { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const backupDir = output + ? path.resolve(process.cwd(), output) + : path.join(BACKUP_ROOT, ts); + + await fs.mkdir(backupDir, { recursive: true }); + + let gitCommit = null; + try { + gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim(); + } catch { + // Not in a git repo or git unavailable + } + + const captured = []; + const skipped = []; + + for (const relPath of STATE_SOURCES) { + const src = path.resolve(process.cwd(), relPath); + const dest = path.join(backupDir, relPath.replace(/\//g, '__')); + try { + await fs.copyFile(src, dest); + captured.push(relPath); + } catch (e) { + if (e.code === 'ENOENT') { + skipped.push({ path: relPath, reason: 'not found' }); + } else { + skipped.push({ path: relPath, reason: e.message }); + } + } + } + + const manifest = { + createdAt: new Date().toISOString(), + gitCommit, + backupDir, + captured, + skipped, + }; + + await fs.writeFile( + path.join(backupDir, 'backup-manifest.json'), + JSON.stringify(manifest, null, 2) + ); + + console.log(JSON.stringify({ success: true, ...manifest }, null, 2)); + return { backupDir, manifest }; +} + +/** + * Lists all available backups under .torch/backups/, newest first. + * + * @returns {Promise>} + */ +export async function listBackups() { + try { + const entries = await fs.readdir(BACKUP_ROOT, { withFileTypes: true }); + const backups = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const backupDir = path.join(BACKUP_ROOT, entry.name); + let manifest = null; + try { + manifest = JSON.parse(await fs.readFile(path.join(backupDir, 'backup-manifest.json'), 'utf8')); + } catch { + // No manifest + } + backups.push({ + id: entry.name, + createdAt: manifest?.createdAt ?? null, + gitCommit: manifest?.gitCommit ?? null, + captured: manifest?.captured ?? [], + backupDir, + }); + } + // Newest first (ISO timestamp sort) + backups.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')); + return backups; + } catch { + return []; + } +} diff --git a/src/cmd-rollback.mjs b/src/cmd-rollback.mjs index 1af450a..e963928 100644 --- a/src/cmd-rollback.mjs +++ b/src/cmd-rollback.mjs @@ -1,12 +1,23 @@ -import { rollbackPrompt } from './services/governance/index.js'; +import { rollbackPrompt, listPromptVersions } from './services/governance/index.js'; import { ExitError } from './errors.mjs'; -export async function cmdRollback(target, strategy) { +export async function cmdRollback(target, strategy, { list = false } = {}) { if (!target) { - console.error('Usage: torch-lock rollback --target [--strategy ]'); + console.error('Usage: torch-lock rollback --target [--strategy ] [--list]'); throw new ExitError(1, 'Missing target'); } + if (list) { + try { + const versions = await listPromptVersions(target); + console.log(JSON.stringify(versions, null, 2)); + } catch (e) { + console.error(`Failed to list versions: ${e.message}`); + throw new ExitError(1, 'List versions failed'); + } + return; + } + const effectiveStrategy = strategy || 'latest'; try { diff --git a/src/lib.mjs b/src/lib.mjs index 5a8421b..69bdf54 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -723,7 +723,18 @@ export async function main(argv) { } case 'rollback': { - await cmdRollback(args.target, args.strategy); + await cmdRollback(args.target, args.strategy, { list: args.list }); + break; + } + + case 'backup': { + const { cmdBackup, listBackups } = await import('./cmd-backup.mjs'); + if (args.list) { + const backups = await listBackups(); + console.log(JSON.stringify(backups, null, 2)); + } else { + await cmdBackup({ output: args.output }); + } break; } diff --git a/src/services/governance/index.js b/src/services/governance/index.js index f9179ea..f88ccce 100644 --- a/src/services/governance/index.js +++ b/src/services/governance/index.js @@ -175,18 +175,26 @@ export async function applyProposal(id) { 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`); + // Filename: __.md for deterministic sort by name + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const baseName = path.basename(meta.target, '.md'); + const archiveName = `${baseName}_${ts}_${hash}.md`; + const archivePath = path.join(archiveDir, archiveName); await fs.writeFile(archivePath, oldContent); + + // Write sidecar metadata for rich version listing + const archiveMeta = { + proposalId: id, + author: meta.author, + reason: meta.reason, + target: meta.target, + hash, + archivedAt: new Date().toISOString(), + }; + await fs.writeFile(archivePath.replace(/\.md$/, '.meta.json'), JSON.stringify(archiveMeta, null, 2)); } catch (_e) { // If file didn't exist, nothing to archive } @@ -228,6 +236,78 @@ export async function rejectProposal(id, reason) { return { success: true }; } +export async function listPromptVersions(target) { + // target is relative path e.g. src/prompts/daily/agent.md + const archiveDir = path.join(HISTORY_DIR, path.dirname(target)); + const targetBase = path.basename(target, '.md'); + const prefix = targetBase + '_'; + + const versions = []; + try { + const files = await fs.readdir(archiveDir); + const archiveFiles = files.filter(f => f.startsWith(prefix) && f.endsWith('.md')); + + for (const file of archiveFiles) { + const filePath = path.join(archiveDir, file); + const metaPath = filePath.replace(/\.md$/, '.meta.json'); + + let meta = null; + try { + meta = JSON.parse(await fs.readFile(metaPath, 'utf8')); + } catch { + // No sidecar — legacy archive or missing + } + + // Extract timestamp and hash from filename: __.md + // Legacy format: _.md (40-char hex) + const withoutPrefix = file.slice(prefix.length, -3); // strip prefix and .md + const parts = withoutPrefix.split('_'); + let archivedAt = null; + let hash = null; + + if (parts.length >= 2) { + // New format: timestamp_hash (timestamp has dashes instead of colons) + hash = parts[parts.length - 1]; + const tsPart = parts.slice(0, -1).join('_'); + // Convert back: YYYY-MM-DDTHH-MM-SS-mmmZ -> ISO + archivedAt = tsPart.replace( + /^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, + '$1:$2:$3.$4Z' + ); + if (archivedAt === tsPart) archivedAt = null; // parse failed + } else { + // Legacy: single part = hash only, use file stat for date + hash = withoutPrefix; + try { + const stat = await fs.stat(filePath); + archivedAt = stat.mtime.toISOString(); + } catch { + // ignore + } + } + + versions.push({ + filename: file, + archivedAt: meta?.archivedAt ?? archivedAt, + hash: meta?.hash ?? hash, + proposalId: meta?.proposalId ?? null, + author: meta?.author ?? null, + reason: meta?.reason ?? null, + }); + } + } catch (_e) { + // archiveDir doesn't exist — no versions yet + } + + // Sort newest first, using archivedAt string (ISO sorts lexicographically) + versions.sort((a, b) => { + if (a.archivedAt && b.archivedAt) return b.archivedAt.localeCompare(a.archivedAt); + return 0; + }); + + return versions; +} + 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)); @@ -237,52 +317,36 @@ export async function rollbackPrompt(target, hashOrStrategy = 'latest') { 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'); - } + const versions = await listPromptVersions(target); + if (versions.length > 0) { + sourceName = versions[0].filename; + 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'); - } + // Try to find file containing the hash or timestamp fragment + const files = await fs.readdir(archiveDir); + const match = files.find(f => f.includes(hashOrStrategy) && f.endsWith('.md')); + if (match) { + sourceName = match; + sourceContent = await fs.readFile(path.join(archiveDir, match), 'utf8'); + } } } catch (_e) { - // Archive lookup failed + // Archive lookup failed } if (sourceContent) { - await fs.writeFile(absoluteTarget, sourceContent); - return { success: true, source: 'archive', restored: sourceName }; + 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 }; + const commit = hashOrStrategy === 'latest' ? 'HEAD' : hashOrStrategy; + 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})`, { cause: e }); + throw new Error(`Rollback failed: Local archive not found and git failed (${e.message})`, { cause: e }); } } diff --git a/test/governance.test.mjs b/test/governance.test.mjs new file mode 100644 index 0000000..db4f831 --- /dev/null +++ b/test/governance.test.mjs @@ -0,0 +1,374 @@ +/** + * Governance: Prompt Versioning and Rollback Tests + * + * Scenarios tested: + * SCN-proposal-create : createProposal writes correct on-disk structure + * SCN-proposal-list : listProposals returns all proposals; status filter works + * SCN-proposal-reject : rejectProposal marks status and records reason + * SCN-archive-naming : applyProposal archives with __.md + sidecar + * SCN-version-list : listPromptVersions returns versions newest-first with metadata + * SCN-rollback-latest : rollbackPrompt --strategy latest restores most-recent archive + * SCN-rollback-hash : rollbackPrompt --strategy restores the matching archive + * SCN-rollback-no-archive: rollbackPrompt falls through to git when no archive exists + */ + +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CWD = process.cwd(); +const PROPOSALS_DIR = path.join(CWD, 'src/proposals'); +const HISTORY_DIR = path.join(CWD, '.torch/prompt-history'); + +// A real daily prompt that is guaranteed to exist (passes allowlist) +const TEST_TARGET = 'src/prompts/daily/audit-agent.md'; +const ABS_TARGET = path.join(CWD, TEST_TARGET); + +let originalPromptContent = null; +const createdProposalIds = []; + +// Read governance functions fresh each time (they're already cached by Node's ESM cache, +// but that's fine — we need stable CWD-based paths for integration tests). +const { + createProposal, + listProposals, + getProposal, + rejectProposal, + applyProposal, + listPromptVersions, + rollbackPrompt, +} = await import('../src/services/governance/index.js'); + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +before(async () => { + // Save original prompt content so we can restore it after applyProposal tests + originalPromptContent = await fsp.readFile(ABS_TARGET, 'utf8'); +}); + +after(async () => { + // Restore original prompt content if a test changed it + if (originalPromptContent !== null) { + try { + const current = await fsp.readFile(ABS_TARGET, 'utf8'); + if (current !== originalPromptContent) { + await fsp.writeFile(ABS_TARGET, originalPromptContent); + } + } catch { + // best-effort + } + } + + // Remove test proposals written during tests + for (const id of createdProposalIds) { + const dir = path.join(PROPOSALS_DIR, id); + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + + // Remove test archives written during tests (keep real ones, only delete test-* prefixed) + const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET)); + try { + const files = await fsp.readdir(archiveDir); + for (const f of files) { + if (f.startsWith('__test__')) { + fs.rmSync(path.join(archiveDir, f), { force: true }); + } + } + } catch { /* ignore */ } +}); + +// --------------------------------------------------------------------------- +// SCN-proposal-create +// --------------------------------------------------------------------------- + +test('SCN-proposal-create: createProposal writes meta.json, new.md, change.diff', async () => { + // Given: a valid target path and new content + const newContent = originalPromptContent + '\n'; + const agent = '__test-agent__'; + + // When: a proposal is created + const { id } = await createProposal({ + agent, + target: TEST_TARGET, + newContent, + reason: 'Test proposal for SCN-proposal-create', + }); + + createdProposalIds.push(id); + + // Then: the proposal directory contains the required files + const proposalDir = path.join(PROPOSALS_DIR, id); + assert.ok(fs.existsSync(path.join(proposalDir, 'meta.json')), 'meta.json exists'); + assert.ok(fs.existsSync(path.join(proposalDir, 'new.md')), 'new.md exists'); + assert.ok(fs.existsSync(path.join(proposalDir, 'change.diff')), 'change.diff exists'); + + const meta = JSON.parse(fs.readFileSync(path.join(proposalDir, 'meta.json'), 'utf8')); + assert.equal(meta.author, agent, 'author recorded'); + assert.equal(meta.target, TEST_TARGET, 'target recorded'); + assert.equal(meta.status, 'pending', 'initial status is pending'); + assert.equal(meta.reason, 'Test proposal for SCN-proposal-create', 'reason recorded'); + + const savedContent = fs.readFileSync(path.join(proposalDir, 'new.md'), 'utf8'); + assert.equal(savedContent, newContent, 'new.md contains proposed content'); +}); + +// --------------------------------------------------------------------------- +// SCN-proposal-list +// --------------------------------------------------------------------------- + +test('SCN-proposal-list: listProposals returns created proposal; status filter works', async () => { + // Given: at least one pending proposal exists (created in previous test) + // When: listing all proposals + const all = await listProposals(); + assert.ok(Array.isArray(all), 'listProposals returns an array'); + assert.ok(all.length >= 1, 'at least one proposal returned'); + + const testProposals = all.filter(p => p.author === '__test-agent__'); + assert.ok(testProposals.length >= 1, 'test proposal is in the list'); + assert.equal(testProposals[0].status, 'pending', 'status is pending'); + + // When: filtering by status that returns nothing + const rejected = all.filter(p => p.status === 'rejected' && p.author === '__test-agent__'); + assert.equal(rejected.length, 0, 'no rejected proposals from our test agent yet'); +}); + +// --------------------------------------------------------------------------- +// SCN-proposal-reject +// --------------------------------------------------------------------------- + +test('SCN-proposal-reject: rejectProposal marks proposal with rejected status and reason', async () => { + // Given: an existing pending proposal + const newContent = originalPromptContent + '\n'; + const { id } = await createProposal({ + agent: '__test-agent__', + target: TEST_TARGET, + newContent, + reason: 'Will be rejected', + }); + createdProposalIds.push(id); + + // When: the proposal is rejected + const rejectionReason = 'Does not meet EXIT CRITERIA standard'; + await rejectProposal(id, rejectionReason); + + // Then: the meta.json reflects rejected status + const proposal = await getProposal(id); + assert.equal(proposal.meta.status, 'rejected', 'status is rejected'); + assert.equal(proposal.meta.rejectionReason, rejectionReason, 'rejection reason recorded'); + assert.ok(proposal.meta.rejectedAt, 'rejectedAt timestamp set'); +}); + +// --------------------------------------------------------------------------- +// SCN-archive-naming: applyProposal creates __.md + .meta.json sidecar +// --------------------------------------------------------------------------- + +test('SCN-archive-naming: applyProposal archives with timestamp_hash filename and sidecar', async () => { + // Given: a valid pending proposal for a real prompt file + const newContent = originalPromptContent.replace( + /^(# )/m, + '# [VERSIONING TEST MARKER] ' + ); + // Ensure newContent still passes contract validation + const hasContract = /Shared contract \(required\):/i.test(newContent) && + /Required startup \+ artifacts \+ memory \+ issue capture/i.test(newContent); + if (!hasContract) { + // Skip: the prompt doesn't have required headers; not a governance test failure + return; + } + + const { id } = await createProposal({ + agent: '__test-agent__', + target: TEST_TARGET, + newContent, + reason: 'Archive naming test', + }); + createdProposalIds.push(id); + + const expectedHash = createHash('sha256').update(originalPromptContent).digest('hex'); + + // When: the proposal is applied + await applyProposal(id); + + // Then: find the archive whose sidecar references this specific proposal ID + // (There may be archives from prior test runs with the same hash; we must find ours.) + const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET)); + const baseName = path.basename(TEST_TARGET, '.md'); + const files = fs.readdirSync(archiveDir); + const sidecarFiles = files.filter(f => f.startsWith(baseName + '_') && f.endsWith('.meta.json')); + + let archiveFile = null; + let sidecar = null; + for (const sf of sidecarFiles) { + try { + const s = JSON.parse(fs.readFileSync(path.join(archiveDir, sf), 'utf8')); + if (s.proposalId === id) { + sidecar = s; + archiveFile = sf.replace('.meta.json', '.md'); + break; + } + } catch { /* skip unreadable sidecars */ } + } + + assert.ok(archiveFile, `archive sidecar found for proposal ${id}`); + assert.ok(fs.existsSync(path.join(archiveDir, archiveFile)), 'archive .md file exists'); + + // Filename must contain a timestamp segment (not just hash) + const withoutPrefix = archiveFile.slice(baseName.length + 1, -3); // strip base_ and .md + const parts = withoutPrefix.split('_'); + assert.ok(parts.length >= 2, 'filename has timestamp + hash parts'); + + assert.equal(sidecar.proposalId, id, 'sidecar references the proposal ID'); + assert.equal(sidecar.author, '__test-agent__', 'sidecar records author'); + assert.equal(sidecar.hash, expectedHash, 'sidecar records SHA-256 hash'); + assert.ok(sidecar.archivedAt, 'sidecar records archivedAt timestamp'); + + // Applied content is the new content + const applied = fs.readFileSync(ABS_TARGET, 'utf8'); + assert.equal(applied, newContent, 'new content was written to target file'); +}); + +// --------------------------------------------------------------------------- +// SCN-version-list: listPromptVersions returns metadata sorted newest first +// --------------------------------------------------------------------------- + +test('SCN-version-list: listPromptVersions returns versions newest-first with metadata', async () => { + // Given: two manually created archive files with different timestamps + const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET)); + await fsp.mkdir(archiveDir, { recursive: true }); + + const baseName = '__test__versioning-probe'; + const ts1 = '2026-01-10T00-00-00-000Z'; + const ts2 = '2026-01-20T00-00-00-000Z'; + const hash1 = 'aaa000'; + const hash2 = 'bbb111'; + + const file1 = `${baseName}_${ts1}_${hash1}.md`; + const file2 = `${baseName}_${ts2}_${hash2}.md`; + + fs.writeFileSync(path.join(archiveDir, file1), 'old version'); + fs.writeFileSync(path.join(archiveDir, file2), 'newer version'); + fs.writeFileSync( + path.join(archiveDir, file2.replace('.md', '.meta.json')), + JSON.stringify({ proposalId: 'test-prop', author: 'test-agent', reason: 'v2', hash: hash2, archivedAt: '2026-01-20T00:00:00.000Z' }) + ); + + // When: listing versions for the probe target + const probeTarget = `src/prompts/daily/${baseName}.md`; + const versions = await listPromptVersions(probeTarget); + + // Then: sorted newest first (ts2 > ts1) + assert.ok(versions.length >= 2, 'at least 2 versions returned'); + assert.equal(versions[0].filename, file2, 'newest version is first'); + assert.equal(versions[1].filename, file1, 'older version is second'); + + // Metadata from sidecar is surfaced + assert.equal(versions[0].author, 'test-agent', 'author from sidecar'); + assert.equal(versions[0].reason, 'v2', 'reason from sidecar'); + assert.equal(versions[0].hash, hash2, 'hash from sidecar'); + + // Version without sidecar still has hash extracted from filename + assert.ok(versions[1].hash, 'hash extracted from filename for legacy entry'); +}); + +// --------------------------------------------------------------------------- +// SCN-rollback-latest +// NOTE: Uses a dedicated probe target (not an actual prompt) so there is no +// interference from archives created by other tests or the scheduler. +// --------------------------------------------------------------------------- + +// Probe target: a path inside the allowed subpath structure but with a name +// that will not conflict with real prompts. rollbackPrompt only needs to +// write to this path; the file is cleaned up in after(). +const ROLLBACK_PROBE_TARGET = 'src/prompts/daily/__rollback-probe__.md'; +const ABS_ROLLBACK_PROBE = path.join(CWD, ROLLBACK_PROBE_TARGET); + +test('SCN-rollback-latest: rollbackPrompt restores the most recently archived content', async () => { + // Given: two manually created archive files for the probe target; newest is distinctive + const archiveDir = path.join(HISTORY_DIR, path.dirname(ROLLBACK_PROBE_TARGET)); + await fsp.mkdir(archiveDir, { recursive: true }); + + const baseName = path.basename(ROLLBACK_PROBE_TARGET, '.md'); + const olderTs = '2025-06-01T00-00-00-000Z'; + const newerTs = '2025-12-01T00-00-00-000Z'; + const olderHash = '__older-hash-scn-latest__'; + const newerHash = '__newer-hash-scn-latest__'; + const olderContent = ''; + const newerContent = ''; + + const olderFile = `${baseName}_${olderTs}_${olderHash}.md`; + const newerFile = `${baseName}_${newerTs}_${newerHash}.md`; + + await fsp.writeFile(path.join(archiveDir, olderFile), olderContent); + await fsp.writeFile(path.join(archiveDir, olderFile.replace('.md', '.meta.json')), + JSON.stringify({ archivedAt: '2025-06-01T00:00:00.000Z', hash: olderHash })); + await fsp.writeFile(path.join(archiveDir, newerFile), newerContent); + await fsp.writeFile(path.join(archiveDir, newerFile.replace('.md', '.meta.json')), + JSON.stringify({ archivedAt: '2025-12-01T00:00:00.000Z', hash: newerHash })); + + try { + // When: rolling back with 'latest' strategy + const result = await rollbackPrompt(ROLLBACK_PROBE_TARGET, 'latest'); + + // Then: target file contains the newer archive's content + assert.ok(result.success, 'rollback reported success'); + assert.equal(result.source, 'archive', 'restored from archive, not git'); + assert.equal(result.restored, newerFile, 'restored from newer archive'); + + const restoredContent = await fsp.readFile(ABS_ROLLBACK_PROBE, 'utf8'); + assert.equal(restoredContent, newerContent, 'target file contains newer archived content'); + } finally { + // Cleanup probe artifacts + for (const f of [olderFile, olderFile.replace('.md', '.meta.json'), + newerFile, newerFile.replace('.md', '.meta.json')]) { + try { await fsp.unlink(path.join(archiveDir, f)); } catch { /* ignore */ } + } + try { await fsp.unlink(ABS_ROLLBACK_PROBE); } catch { /* ignore */ } + } +}); + +// --------------------------------------------------------------------------- +// SCN-rollback-hash +// --------------------------------------------------------------------------- + +test('SCN-rollback-hash: rollbackPrompt --strategy restores matching archive', async () => { + // Given: one archive with a distinctive hash fragment for the probe target + const archiveDir = path.join(HISTORY_DIR, path.dirname(ROLLBACK_PROBE_TARGET)); + await fsp.mkdir(archiveDir, { recursive: true }); + + const baseName = path.basename(ROLLBACK_PROBE_TARGET, '.md'); + const specificHash = 'cafecafe1234scn'; + const specificContent = ''; + const specificTs = '2025-09-15T00-00-00-000Z'; + const specificFile = `${baseName}_${specificTs}_${specificHash}.md`; + + await fsp.writeFile(path.join(archiveDir, specificFile), specificContent); + await fsp.writeFile(path.join(archiveDir, specificFile.replace('.md', '.meta.json')), + JSON.stringify({ archivedAt: '2025-09-15T00:00:00.000Z', hash: specificHash })); + + try { + // When: rolling back with the specific hash fragment + const result = await rollbackPrompt(ROLLBACK_PROBE_TARGET, 'cafecafe1234scn'); + + // Then: the correct archive was selected + assert.ok(result.success, 'rollback succeeded'); + assert.equal(result.source, 'archive', 'restored from archive'); + assert.equal(result.restored, specificFile, 'correct archive file selected'); + + const restoredContent = await fsp.readFile(ABS_ROLLBACK_PROBE, 'utf8'); + assert.equal(restoredContent, specificContent, 'content matches the specific archive'); + } finally { + for (const f of [specificFile, specificFile.replace('.md', '.meta.json')]) { + try { await fsp.unlink(path.join(archiveDir, f)); } catch { /* ignore */ } + } + try { await fsp.unlink(ABS_ROLLBACK_PROBE); } catch { /* ignore */ } + } +}); From 54818a624cd57365cd268e8e45218ffb57c70fc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 02:29:15 +0000 Subject: [PATCH 2/3] chore: add .torch/ to .gitignore (runtime prompt-history and backups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .torch/ is created at runtime by torch-lock backup and proposal apply. It holds prompt version archives and state snapshots — operational data that should not be tracked in source control. https://claude.ai/code/session_01Ndq2D72Kp77BYo4owD63uY --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 04607c9..1c79691 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules dist/ .scheduler-memory/ +.torch/ # Logs *.log From 63ed863e1ae3e4b2cea51dca2cb5b18e5a5acd28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 02:36:22 +0000 Subject: [PATCH 3/3] docs: surface prompt versioning and backup in README and TORCH.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: add "Prompt Governance & Versioning CLI" and "State Backup CLI" sections under Usage with full command examples and link to docs/prompt-versioning.md - TORCH.md: add "Prompt Versioning & State Backup" section with proposal→apply→rollback cycle, backup command reference, and link to docs/prompt-versioning.md https://claude.ai/code/session_01Ndq2D72Kp77BYo4owD63uY --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ TORCH.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/README.md b/README.md index ca8b744..9c0d969 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,54 @@ npx --no-install torch-lock unpin-memory --id npx --no-install torch-lock memory-stats ``` +### Prompt Governance & Versioning CLI + +Agent prompts are changed through a governance workflow that archives the previous version before overwriting, enabling full rollback. + +```bash +# Propose a change to an agent prompt +npx --no-install torch-lock proposal create \ + --agent my-agent \ + --target src/prompts/daily/my-agent.md \ + --content /path/to/new-content.md \ + --reason "Add missing EXIT CRITERIA section" + +# List pending proposals +npx --no-install torch-lock proposal list +npx --no-install torch-lock proposal list --status pending + +# Inspect or apply a proposal +npx --no-install torch-lock proposal show --id +npx --no-install torch-lock proposal apply --id +npx --no-install torch-lock proposal reject --id --reason "..." + +# List available archived versions of a prompt +npx --no-install torch-lock rollback --target src/prompts/daily/my-agent.md --list + +# Roll back to the most recent archived version +npx --no-install torch-lock rollback --target src/prompts/daily/my-agent.md + +# Roll back to a specific version by hash fragment +npx --no-install torch-lock rollback --target src/prompts/daily/my-agent.md --strategy +``` + +### State Backup CLI + +Snapshot agent runtime state (long-term memory and scheduler state) to a timestamped directory under `.torch/backups/`. + +```bash +# Create a snapshot +npx --no-install torch-lock backup + +# List existing snapshots +npx --no-install torch-lock backup --list + +# Write snapshot to a custom directory +npx --no-install torch-lock backup --output /path/to/backup-dir +``` + +See [docs/prompt-versioning.md](docs/prompt-versioning.md) for the full workflow, restore procedure, and storage layout. + ### Configuration (`torch-config.json`) Create a `torch-config.json` file in your project root to configure TORCH for your repository. You can use the included `torch-config.example.json` as a template. diff --git a/TORCH.md b/TORCH.md index ee5767e..7a41b6e 100644 --- a/TORCH.md +++ b/TORCH.md @@ -425,6 +425,54 @@ The lock CLI resolves roster names in this order: 3. Preserve database state for post-incident analysis; do not drop or rewrite memory tables during rollback. 4. Keep prune actions in `dry-run` (or disabled) until lifecycle policy and data integrity are revalidated. +## Prompt Versioning & State Backup + +### How prompt changes are governed + +Every agent prompt in `src/prompts/daily/` and `src/prompts/weekly/` must be changed through the governance workflow — direct file edits skip the archive and break rollback lineage. + +**Proposal → Apply → Rollback cycle:** + +```bash +# 1. Propose a change +torch-lock proposal create \ + --agent \ + --target src/prompts/daily/.md \ + --content /path/to/new.md \ + --reason "reason" + +# 2. Review and apply +torch-lock proposal list --status pending +torch-lock proposal show --id +torch-lock proposal apply --id # archives old version, writes new +torch-lock proposal reject --id --reason "..." + +# 3. Inspect available versions +torch-lock rollback --target src/prompts/daily/.md --list + +# 4. Roll back (defaults to most recent archive; falls back to git) +torch-lock rollback --target src/prompts/daily/.md +torch-lock rollback --target src/prompts/daily/.md --strategy +``` + +Archives are stored in `.torch/prompt-history/` with filenames that embed an ISO timestamp and SHA-256 hash (`__.md`) alongside a `.meta.json` sidecar recording who applied the change, why, and when. + +### State backup + +Runtime state lives outside git. Snapshot it before destructive operations or on a schedule: + +```bash +torch-lock backup # creates .torch/backups// +torch-lock backup --list # list all snapshots +``` + +Files captured per snapshot: +- `.scheduler-memory/memory-store.json` (agent long-term memory) +- `task-logs/daily/.scheduler-run-state.json` (scheduler deferral state) + +See [docs/prompt-versioning.md](docs/prompt-versioning.md) for the full reference including restore steps and storage layout. + + ## Offline & Air-Gapped Installation TORCH is designed to operate in diverse environments, including high-security, air-gapped, or offline networks where direct access to the npm registry is restricted.