UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

640 lines (554 loc) 17 kB
/** * Snapshot Manager for External Ralph Loop * * Captures pre-session and post-session state to enable comprehensive * assessment of long-running (6-8 hour) Claude sessions. * * @implements @.aiwg/requirements/design-ralph-external.md */ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; import { join, relative } from 'path'; import { createHash } from 'crypto'; import { execSync } from 'child_process'; /** * @typedef {Object} GitStatus * @property {string} branch - Current branch name * @property {string} commit - Current HEAD commit hash * @property {string[]} staged - Staged files * @property {string[]} unstaged - Modified but unstaged files * @property {string[]} untracked - Untracked files */ /** * @typedef {Object} FileSnapshot * @property {string} path - Relative path from project root * @property {string} hash - SHA256 hash of file content * @property {number} size - File size in bytes * @property {string} mtime - Last modification time (ISO) */ /** * @typedef {Object} AiwgState * @property {FileSnapshot[]} files - All files in .aiwg/ directory * @property {Object|null} ralphState - Internal Ralph state if exists */ /** * @typedef {Object} PreSnapshot * @property {string} timestamp - ISO timestamp of snapshot * @property {GitStatus} git - Git repository status * @property {FileSnapshot[]} keyFiles - Key project files (package.json, etc.) * @property {AiwgState} aiwg - .aiwg directory state */ /** * @typedef {Object} FileDiff * @property {string} path - File path * @property {string} status - added|modified|deleted * @property {string} [diff] - Git diff output for modified files */ /** * @typedef {Object} TestResults * @property {boolean} found - Whether test results were found * @property {string} [file] - Path to test output file * @property {Object} [summary] - Parsed test summary */ /** * @typedef {Object} PostSnapshot * @property {string} timestamp - ISO timestamp of snapshot * @property {GitStatus} git - Git repository status * @property {FileDiff[]} fileDiffs - Files changed since pre-snapshot * @property {string[]} commits - Commits made during session * @property {AiwgState} aiwg - Updated .aiwg directory state * @property {TestResults} tests - Test results if available */ /** * @typedef {Object} SnapshotDiff * @property {string} duration - Human-readable duration * @property {number} durationMs - Duration in milliseconds * @property {string[]} filesAdded - New files created * @property {string[]} filesModified - Existing files modified * @property {string[]} filesDeleted - Files deleted * @property {string[]} aiwgArtifactsCreated - New .aiwg artifacts * @property {string[]} aiwgArtifactsUpdated - Updated .aiwg artifacts * @property {number} commitCount - Number of commits made * @property {string[]} commits - Commit messages * @property {boolean} hasTests - Whether test results were found * @property {Object} [testSummary] - Test summary if available */ /** * Key files to track across sessions */ const KEY_FILES = [ 'package.json', 'package-lock.json', 'CLAUDE.md', 'CHANGELOG.md', '.gitignore', 'tsconfig.json', ]; /** * Test output patterns to search for */ const TEST_PATTERNS = [ '.aiwg/ralph-external/outputs/*.log', 'test-results.json', 'coverage/lcov-report/index.html', '.aiwg/testing/*.md', ]; export class SnapshotManager { /** * @param {string} projectRoot - Project root directory */ constructor(projectRoot) { this.projectRoot = projectRoot; } /** * Capture pre-session snapshot * @param {string} projectRoot - Project root directory * @param {string} iterationDir - Iteration directory for outputs * @returns {PreSnapshot} */ capturePreSnapshot(projectRoot, iterationDir) { const timestamp = new Date().toISOString(); // Ensure iteration directory exists if (!existsSync(iterationDir)) { mkdirSync(iterationDir, { recursive: true }); } // Capture git status const git = this.captureGitStatus(projectRoot); // Capture key files const keyFiles = this.captureKeyFiles(projectRoot); // Capture .aiwg state const aiwg = this.captureAiwgState(projectRoot); /** @type {PreSnapshot} */ const snapshot = { timestamp, git, keyFiles, aiwg, }; // Save to file const snapshotPath = join(iterationDir, 'pre-snapshot.json'); writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2)); return snapshot; } /** * Capture post-session snapshot * @param {string} projectRoot - Project root directory * @param {string} iterationDir - Iteration directory for outputs * @returns {PostSnapshot} */ capturePostSnapshot(projectRoot, iterationDir) { const timestamp = new Date().toISOString(); // Load pre-snapshot for comparison const preSnapshotPath = join(iterationDir, 'pre-snapshot.json'); if (!existsSync(preSnapshotPath)) { throw new Error('Pre-snapshot not found - cannot capture post-snapshot'); } /** @type {PreSnapshot} */ const preSnapshot = JSON.parse(readFileSync(preSnapshotPath, 'utf8')); // Capture current git status const git = this.captureGitStatus(projectRoot); // Calculate file diffs const fileDiffs = this.calculateFileDiffs(projectRoot, preSnapshot.git.commit, git.commit); // Get commits made during session const commits = this.getCommitsSince(projectRoot, preSnapshot.git.commit); // Capture updated .aiwg state const aiwg = this.captureAiwgState(projectRoot); // Try to find test results const tests = this.findTestResults(projectRoot, iterationDir); /** @type {PostSnapshot} */ const snapshot = { timestamp, git, fileDiffs, commits, aiwg, tests, }; // Save to file const snapshotPath = join(iterationDir, 'post-snapshot.json'); writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2)); return snapshot; } /** * Calculate diff between pre and post snapshots * @param {PreSnapshot} preSnapshot * @param {PostSnapshot} postSnapshot * @returns {SnapshotDiff} */ calculateDiff(preSnapshot, postSnapshot) { // Calculate duration const preTime = new Date(preSnapshot.timestamp); const postTime = new Date(postSnapshot.timestamp); const durationMs = postTime - preTime; const duration = this.formatDuration(durationMs); // Categorize file changes const filesAdded = []; const filesModified = []; const filesDeleted = []; for (const diff of postSnapshot.fileDiffs) { if (diff.status === 'added') { filesAdded.push(diff.path); } else if (diff.status === 'modified') { filesModified.push(diff.path); } else if (diff.status === 'deleted') { filesDeleted.push(diff.path); } } // Track .aiwg changes const preAiwgPaths = new Set(preSnapshot.aiwg.files.map(f => f.path)); const postAiwgPaths = new Set(postSnapshot.aiwg.files.map(f => f.path)); const aiwgArtifactsCreated = []; const aiwgArtifactsUpdated = []; for (const file of postSnapshot.aiwg.files) { if (!preAiwgPaths.has(file.path)) { aiwgArtifactsCreated.push(file.path); } else { const preFile = preSnapshot.aiwg.files.find(f => f.path === file.path); if (preFile && preFile.hash !== file.hash) { aiwgArtifactsUpdated.push(file.path); } } } // Commit analysis const commitCount = postSnapshot.commits.length; const commits = postSnapshot.commits; // Test analysis const hasTests = postSnapshot.tests.found; const testSummary = postSnapshot.tests.summary || null; /** @type {SnapshotDiff} */ return { duration, durationMs, filesAdded, filesModified, filesDeleted, aiwgArtifactsCreated, aiwgArtifactsUpdated, commitCount, commits, hasTests, testSummary, }; } /** * Capture current git status * @param {string} projectRoot * @returns {GitStatus} */ captureGitStatus(projectRoot) { try { // Get current branch const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectRoot, encoding: 'utf8', }).trim(); // Get current commit const commit = execSync('git rev-parse HEAD', { cwd: projectRoot, encoding: 'utf8', }).trim(); // Get staged files const stagedOutput = execSync('git diff --cached --name-only', { cwd: projectRoot, encoding: 'utf8', }).trim(); const staged = stagedOutput ? stagedOutput.split('\n') : []; // Get unstaged files const unstagedOutput = execSync('git diff --name-only', { cwd: projectRoot, encoding: 'utf8', }).trim(); const unstaged = unstagedOutput ? unstagedOutput.split('\n') : []; // Get untracked files const untrackedOutput = execSync('git ls-files --others --exclude-standard', { cwd: projectRoot, encoding: 'utf8', }).trim(); const untracked = untrackedOutput ? untrackedOutput.split('\n') : []; return { branch, commit, staged, unstaged, untracked, }; } catch (error) { // Not a git repository or git command failed return { branch: 'unknown', commit: 'unknown', staged: [], unstaged: [], untracked: [], }; } } /** * Capture key project files * @param {string} projectRoot * @returns {FileSnapshot[]} */ captureKeyFiles(projectRoot) { const snapshots = []; for (const filename of KEY_FILES) { const filePath = join(projectRoot, filename); if (existsSync(filePath)) { snapshots.push(this.createFileSnapshot(projectRoot, filePath)); } } return snapshots; } /** * Capture .aiwg directory state * @param {string} projectRoot * @returns {AiwgState} */ captureAiwgState(projectRoot) { const aiwgDir = join(projectRoot, '.aiwg'); const files = []; if (existsSync(aiwgDir)) { this.walkDirectory(aiwgDir, (filePath) => { files.push(this.createFileSnapshot(projectRoot, filePath)); }); } // Try to load internal Ralph state let ralphState = null; const ralphStatePath = join(aiwgDir, 'ralph', 'current-loop.json'); if (existsSync(ralphStatePath)) { try { ralphState = JSON.parse(readFileSync(ralphStatePath, 'utf8')); } catch (e) { // Invalid JSON, ignore } } return { files, ralphState, }; } /** * Create snapshot of a single file * @param {string} projectRoot * @param {string} filePath * @returns {FileSnapshot} */ createFileSnapshot(projectRoot, filePath) { const content = readFileSync(filePath); const hash = createHash('sha256').update(content).digest('hex'); const stats = statSync(filePath); return { path: relative(projectRoot, filePath), hash, size: stats.size, mtime: stats.mtime.toISOString(), }; } /** * Walk directory recursively * @param {string} dir * @param {Function} callback */ walkDirectory(dir, callback) { if (!existsSync(dir)) { return; } const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { this.walkDirectory(fullPath, callback); } else if (entry.isFile()) { callback(fullPath); } } } /** * Calculate file diffs between commits * @param {string} projectRoot * @param {string} fromCommit * @param {string} toCommit * @returns {FileDiff[]} */ calculateFileDiffs(projectRoot, fromCommit, toCommit) { if (fromCommit === 'unknown' || toCommit === 'unknown') { return []; } try { // Get list of changed files const diffOutput = execSync(`git diff --name-status ${fromCommit}..${toCommit}`, { cwd: projectRoot, encoding: 'utf8', }).trim(); if (!diffOutput) { return []; } const diffs = []; const lines = diffOutput.split('\n'); for (const line of lines) { const parts = line.split('\t'); if (parts.length < 2) continue; const statusCode = parts[0]; const filePath = parts[1]; let status; if (statusCode === 'A') { status = 'added'; } else if (statusCode === 'D') { status = 'deleted'; } else if (statusCode.startsWith('M')) { status = 'modified'; } else { status = 'modified'; // R (renamed), C (copied), etc. } /** @type {FileDiff} */ const diff = { path: filePath, status, }; // Get actual diff for modified files if (status === 'modified') { try { diff.diff = execSync(`git diff ${fromCommit}..${toCommit} -- "${filePath}"`, { cwd: projectRoot, encoding: 'utf8', }); } catch (e) { // Diff failed, skip } } diffs.push(diff); } return diffs; } catch (error) { return []; } } /** * Get commits since a specific commit * @param {string} projectRoot * @param {string} sinceCommit * @returns {string[]} */ getCommitsSince(projectRoot, sinceCommit) { if (sinceCommit === 'unknown') { return []; } try { const output = execSync(`git log ${sinceCommit}..HEAD --pretty=format:"%H %s"`, { cwd: projectRoot, encoding: 'utf8', }).trim(); if (!output) { return []; } return output.split('\n').map(line => line.trim()); } catch (error) { return []; } } /** * Find test results in common locations * @param {string} projectRoot * @param {string} iterationDir * @returns {TestResults} */ findTestResults(projectRoot, iterationDir) { // Check iteration output logs first const outputDir = join(iterationDir, '..'); if (existsSync(outputDir)) { const files = readdirSync(outputDir); for (const file of files) { if (file.endsWith('.log')) { const content = readFileSync(join(outputDir, file), 'utf8'); const summary = this.parseTestOutput(content); if (summary) { return { found: true, file: join(outputDir, file), summary, }; } } } } // Check common test result locations const testPaths = [ join(projectRoot, 'test-results.json'), join(projectRoot, '.aiwg', 'testing', 'test-results.md'), join(projectRoot, 'coverage', 'coverage-summary.json'), ]; for (const path of testPaths) { if (existsSync(path)) { const content = readFileSync(path, 'utf8'); const summary = this.parseTestOutput(content); if (summary) { return { found: true, file: path, summary, }; } } } return { found: false, }; } /** * Parse test output to extract summary * @param {string} content * @returns {Object|null} */ parseTestOutput(content) { // Try to parse as JSON first try { const json = JSON.parse(content); if (json.numTotalTests || json.numPassedTests) { return { type: 'jest', total: json.numTotalTests || 0, passed: json.numPassedTests || 0, failed: json.numFailedTests || 0, skipped: json.numPendingTests || 0, }; } } catch (e) { // Not JSON, try text parsing } // Look for common test output patterns const patterns = [ // Jest/Vitest: "Tests: 5 passed, 5 total" /Tests:\s+(\d+)\s+passed,\s+(\d+)\s+total/i, // Mocha: "5 passing" /(\d+)\s+passing/i, // TAP: "# tests 5" /#\s+tests\s+(\d+)/i, ]; for (const pattern of patterns) { const match = content.match(pattern); if (match) { return { type: 'text', snippet: match[0], }; } } return null; } /** * Format duration in human-readable form * @param {number} ms - Duration in milliseconds * @returns {string} */ formatDuration(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}h ${minutes % 60}m`; } else if (minutes > 0) { return `${minutes}m ${seconds % 60}s`; } else { return `${seconds}s`; } } } export default SnapshotManager;