aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
849 lines (742 loc) • 25.1 kB
JavaScript
const execa = require('execa');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const WorktreeManager = require('./worktree-manager');
/**
* ProjectStatusLoader - Dynamic project status for agent activation context
*
* Story 6.1.2.4: Captures git state, recent work, and current story/epic
* for display in agent greetings across all 11 AIOS agents.
*
* Story ACT-3: Reliability overhaul
* - Event-driven cache invalidation via git state change detection
* - Multi-terminal safe file locking for cache writes
* - Worktree-aware cache paths
* - Performance optimized (<100ms cached, <500ms regeneration)
*
* Features:
* - Git integration (branch, status, recent commits)
* - Current story/epic detection from docs/stories/
* - Smart cache invalidation via .git/HEAD and .git/index mtime
* - Multi-terminal lock file protection
* - Worktree-aware cache paths
* - Cross-platform support (Windows/Linux/macOS)
* - Graceful fallback for non-git projects
*/
/**
* Default lock timeout in milliseconds.
* If a lock cannot be acquired within this time, skip locking and proceed.
* @type {number}
*/
const LOCK_TIMEOUT_MS = 3000;
/**
* Lock file stale threshold in milliseconds.
* If a lock file is older than this, it is considered stale and will be removed.
* @type {number}
*/
const LOCK_STALE_MS = 10000;
/**
* Active-session cache TTL in seconds.
* Used when git state has NOT changed since last cache write.
* @type {number}
*/
const ACTIVE_SESSION_TTL = 15;
/**
* Idle cache TTL in seconds.
* Used as the maximum TTL fallback even if git state check fails.
* @type {number}
*/
const IDLE_TTL = 60;
class ProjectStatusLoader {
constructor(rootPath = null) {
this.rootPath = rootPath || process.cwd();
// Load config values (QA Fix: Issue 6.1.2.4-I1)
this.config = this.loadConfig();
this.maxModifiedFiles = this.config?.projectStatus?.maxModifiedFiles || 5;
this.maxRecentCommits = this.config?.projectStatus?.maxRecentCommits || 2;
// ACT-11: Cache git dir from constructor to avoid duplicate execSync calls
this._resolvedGitDir = null;
this._isGitRepo = false;
// ACT-3: Determine cache file path (worktree-aware)
// ACT-11: _resolveCacheFilePath now also caches _resolvedGitDir and _isGitRepo
this.cacheFile = this._resolveCacheFilePath();
this.lockFile = this.cacheFile + '.lock';
// ACT-3: Smart TTLs - active session vs idle
this.activeSessionTTL = ACTIVE_SESSION_TTL;
this.idleTTL = IDLE_TTL;
// Keep cacheTTL for backward compat (used in isCacheValid as fallback)
this.cacheTTL = IDLE_TTL;
// ACT-3: Track git state fingerprint for change detection
this._lastGitFingerprint = null;
// ACT-11: Support skipGitStatus config for slow environments
this.skipGitStatus = this.config?.projectStatus?.skipGitStatus || false;
}
/**
* Load configuration from core-config.yaml
*
* @returns {Object|null} Config object or null if not found
*/
loadConfig() {
try {
const configPath = path.join(this.rootPath, '.aios-core', 'core-config.yaml');
const configContent = fsSync.readFileSync(configPath, 'utf8');
return yaml.load(configContent);
} catch (error) {
// Config not found - use defaults
return null;
}
}
/**
* ACT-3 Task 4: Resolve cache file path with worktree awareness.
*
* ACT-11: Caches _resolvedGitDir and _isGitRepo for reuse by
* getGitStateFingerprint() and isGitRepository(), eliminating
* duplicate execSync calls later in the pipeline.
*
* If running inside a git worktree (not the main working tree),
* uses a worktree-specific cache file to prevent cross-worktree conflicts.
*
* @returns {string} Resolved cache file path
* @private
*/
_resolveCacheFilePath() {
try {
const { execSync } = require('child_process');
// Get the git directory for the current worktree
const gitDir = execSync('git rev-parse --git-dir', {
cwd: this.rootPath,
encoding: 'utf8',
timeout: 2000,
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
// Get the common git directory (shared across worktrees)
const gitCommonDir = execSync('git rev-parse --git-common-dir', {
cwd: this.rootPath,
encoding: 'utf8',
timeout: 2000,
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
// ACT-11: Cache the resolved git dir for getGitStateFingerprint()
const normalizedGitDir = path.resolve(this.rootPath, gitDir);
this._resolvedGitDir = normalizedGitDir;
this._isGitRepo = true;
// Normalize paths for comparison
const normalizedCommonDir = path.resolve(this.rootPath, gitCommonDir);
// If git-dir !== git-common-dir, we are in a worktree
if (normalizedGitDir !== normalizedCommonDir) {
// Create a short hash from the worktree path for a unique cache filename
const worktreeHash = this._hashString(this.rootPath).substring(0, 8);
return path.join(this.rootPath, '.aios', `project-status-${worktreeHash}.yaml`);
}
} catch (error) {
// Not a git repo or git not available - use default path
}
return path.join(this.rootPath, '.aios', 'project-status.yaml');
}
/**
* Simple string hash for creating unique cache file names.
*
* @param {string} str - String to hash
* @returns {string} Hex hash string
* @private
*/
_hashString(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash).toString(16).padStart(8, '0');
}
/**
* ACT-3 Task 1: Get git state fingerprint from .git/HEAD and .git/index mtime.
*
* ACT-11: Reuses _resolvedGitDir from constructor instead of running execSync again.
* This eliminates one synchronous git command (~30-50ms on Windows).
*
* @returns {Promise<string|null>} Fingerprint string or null if not available
*/
async getGitStateFingerprint() {
try {
// ACT-11: Reuse cached git dir from constructor
let resolvedGitDir = this._resolvedGitDir;
if (!resolvedGitDir) {
// Fallback if constructor didn't resolve (shouldn't happen in normal flow)
const { execSync } = require('child_process');
const gitDir = execSync('git rev-parse --git-dir', {
cwd: this.rootPath,
encoding: 'utf8',
timeout: 2000,
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
resolvedGitDir = path.resolve(this.rootPath, gitDir);
}
const headPath = path.join(resolvedGitDir, 'HEAD');
const indexPath = path.join(resolvedGitDir, 'index');
const mtimes = await Promise.all([
fs.stat(headPath).then(s => s.mtimeMs).catch(() => 0),
fs.stat(indexPath).then(s => s.mtimeMs).catch(() => 0),
]);
return `${mtimes[0]}:${mtimes[1]}`;
} catch (error) {
return null;
}
}
/**
* Load project status with smart caching
*
* ACT-3: Uses event-driven cache invalidation instead of fixed 60s TTL.
* Cache is invalidated when git state (HEAD or index) changes.
*
* @returns {Promise<ProjectStatus>} Current project status
*/
async loadProjectStatus() {
try {
// ACT-3: Get current git state fingerprint
const currentFingerprint = await this.getGitStateFingerprint();
// Try to load from cache first
const cached = await this.loadCache();
if (cached && this.isCacheValid(cached, currentFingerprint)) {
return cached.status;
}
// Cache miss or expired - generate fresh status
const status = await this.generateStatus();
// Save to cache with lock protection
await this.saveCacheWithLock(status, currentFingerprint);
return status;
} catch (error) {
console.warn('Project status loading failed, using defaults:', error.message);
return this.getDefaultStatus();
}
}
/**
* Generate fresh project status
*
* ACT-3 Task 5: All git commands run in parallel via Promise.all()
* ACT-11: Supports skipGitStatus config to skip expensive git status command
*
* @returns {Promise<ProjectStatus>}
*/
async generateStatus() {
const isGit = await this.isGitRepository();
if (!isGit) {
return this.getNonGitStatus();
}
// ACT-11: When skipGitStatus is enabled, skip the expensive getModifiedFiles()
const modifiedFilesPromise = this.skipGitStatus
? Promise.resolve({ files: [], totalCount: 0 })
: this.getModifiedFiles();
const [branch, modifiedFilesResult, recentCommits, storyInfo, worktrees] = await Promise.all([
this.getGitBranch(),
modifiedFilesPromise,
this.getRecentCommits(),
this.getCurrentStoryInfo(),
this.getWorktreesStatus(),
]);
const status = {
branch,
modifiedFiles: modifiedFilesResult.files,
modifiedFilesTotalCount: modifiedFilesResult.totalCount,
recentCommits,
currentEpic: storyInfo.epic || null,
currentStory: storyInfo.story || null,
lastUpdate: new Date().toISOString(),
isGitRepo: true,
};
// Story 1.5: Include worktrees section if any exist
if (worktrees) {
status.worktrees = worktrees;
}
return status;
}
/**
* Check if current directory is a git repository
*
* @returns {Promise<boolean>}
*/
async isGitRepository() {
try {
await execa('git', ['rev-parse', '--is-inside-work-tree'], {
cwd: this.rootPath,
stderr: 'ignore',
});
return true;
} catch (error) {
return false;
}
}
/**
* Get current git branch name
*
* @returns {Promise<string>}
*/
async getGitBranch() {
try {
// Try modern git command first (git >= 2.22)
const { stdout } = await execa('git', ['branch', '--show-current'], {
cwd: this.rootPath,
});
return stdout.trim();
} catch (error) {
// Fallback for older git versions
try {
const { stdout } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: this.rootPath,
});
return stdout.trim();
} catch (fallbackError) {
return 'unknown';
}
}
}
/**
* Get modified files from git status
*
* @returns {Promise<{files: string[], totalCount: number}>}
*/
async getModifiedFiles() {
try {
const { stdout } = await execa('git', ['status', '--porcelain'], {
cwd: this.rootPath,
});
if (!stdout) return { files: [], totalCount: 0 };
// Parse porcelain output
const allFiles = stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => {
// Remove status prefix (e.g., " M ", "A ", "?? ")
return line.substring(3).trim();
});
const totalCount = allFiles.length;
const files = allFiles.slice(0, this.maxModifiedFiles); // Use config value
return { files, totalCount };
} catch (error) {
return { files: [], totalCount: 0 };
}
}
/**
* Get recent commits
*
* @returns {Promise<string[]>}
*/
async getRecentCommits() {
try {
const { stdout } = await execa(
'git',
['log', `-${this.maxRecentCommits}`, '--oneline', '--no-decorate'],
{
cwd: this.rootPath,
}
);
if (!stdout) return [];
// Parse commit lines (remove hash, keep message)
const commits = stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => {
// Remove commit hash (first 7-8 characters)
return line.substring(8).trim();
});
return commits;
} catch (error) {
// No commits yet or error
return [];
}
}
/**
* Get worktrees status using WorktreeManager
*
* Story 1.5: Worktree Status Integration
*
* @returns {Promise<Object>} Worktrees status object
*/
async getWorktreesStatus() {
try {
const worktreeManager = new WorktreeManager(this.rootPath);
const worktrees = await worktreeManager.list();
if (worktrees.length === 0) {
return null;
}
const worktreesStatus = {};
for (const wt of worktrees) {
worktreesStatus[wt.storyId] = {
path: wt.path,
branch: wt.branch,
createdAt: wt.createdAt instanceof Date ? wt.createdAt.toISOString() : wt.createdAt,
lastActivity: new Date().toISOString(), // Updated on status check
uncommittedChanges: wt.uncommittedChanges,
status: wt.status,
};
}
return worktreesStatus;
} catch (error) {
// WorktreeManager failed - likely not a git repo or git not available
return null;
}
}
/**
* Detect current story and epic from docs/stories/
*
* Scans for Status: InProgress or Status: In Progress
*
* @returns {Promise<{story: string|null, epic: string|null}>}
*/
async getCurrentStoryInfo() {
try {
const storiesDir = path.join(this.rootPath, 'docs', 'stories');
// Check if stories directory exists
try {
await fs.access(storiesDir);
} catch {
return { story: null, epic: null };
}
// Read all markdown files recursively
const storyFiles = await this.findMarkdownFiles(storiesDir);
for (const file of storyFiles) {
const content = await fs.readFile(file, 'utf8');
// Check for InProgress status
const statusMatch = content.match(
/\*\*Status:\*\*\s*(InProgress|In Progress|🔄\s*InProgress|🔄\s*In Progress)/i
);
if (statusMatch) {
// Extract story ID and epic
const storyIdMatch = content.match(/\*\*Story ID:\*\*\s*([A-Z]+-[\d.]+)/);
const epicMatch = content.match(/\*\*Epic:\*\*\s*([^\n]+)/);
return {
story: storyIdMatch ? storyIdMatch[1] : path.basename(file, '.md'),
epic: epicMatch ? epicMatch[1].trim() : null,
};
}
}
return { story: null, epic: null };
} catch (error) {
return { story: null, epic: null };
}
}
/**
* Find all markdown files in directory recursively
*
* @param {string} dir - Directory to search
* @returns {Promise<string[]>}
*/
async findMarkdownFiles(dir) {
const files = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const subFiles = await this.findMarkdownFiles(fullPath);
files.push(...subFiles);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath);
}
}
} catch (error) {
// Ignore errors (permission denied, etc.)
}
return files;
}
/**
* Load status from cache file
*
* ACT-3 Task 2: Added corrupted cache recovery.
* If the YAML is invalid, delete the file and return null.
*
* @returns {Promise<{status: ProjectStatus, timestamp: number, ttl: number, gitFingerprint: string|null}|null>}
*/
async loadCache() {
try {
const content = await fs.readFile(this.cacheFile, 'utf8');
const parsed = yaml.load(content);
// ACT-3: Validate cache structure (corrupted cache recovery)
if (!parsed || typeof parsed !== 'object' || !parsed.status) {
// Cache is corrupted - delete and regenerate
await this.clearCache();
return null;
}
return parsed;
} catch (error) {
if (error.name === 'YAMLException') {
// Corrupted YAML - delete the file
await this.clearCache();
}
return null;
}
}
/**
* Check if cache is still valid
*
* ACT-3 Task 1: Event-driven invalidation.
* - If git state fingerprint changed, cache is invalid immediately
* - If fingerprint same, use active-session TTL (15s)
* - If fingerprint unavailable, use idle TTL (60s)
*
* @param {{timestamp: number, ttl: number, gitFingerprint: string|null}} cache
* @param {string|null} [currentFingerprint] - Current git state fingerprint
* @returns {boolean}
*/
isCacheValid(cache, currentFingerprint) {
if (!cache || !cache.timestamp) return false;
const age = Date.now() - cache.timestamp;
// ACT-3: Event-driven invalidation via git fingerprint
if (currentFingerprint && cache.gitFingerprint) {
if (cache.gitFingerprint !== currentFingerprint) {
// Git state changed - cache is immediately invalid
return false;
}
// Git state unchanged - use active-session TTL (15s)
return age < this.activeSessionTTL * 1000;
}
// Fallback: No fingerprint available - use idle TTL (60s)
const ttl = cache.ttl || this.cacheTTL;
return age < ttl * 1000;
}
/**
* ACT-3 Task 2: Acquire a lock file for multi-terminal safety.
*
* Uses `fs.open()` with 'wx' flag (exclusive create) for cross-platform file locking.
* Stale locks (older than LOCK_STALE_MS) are automatically cleaned up.
*
* @returns {Promise<boolean>} true if lock acquired, false otherwise
* @private
*/
async _acquireLock() {
const startTime = Date.now();
while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
try {
// Try exclusive create - fails if file exists
// Write lock data (PID + timestamp) directly to the lock file path
const lockData = JSON.stringify({ pid: process.pid, timestamp: Date.now() });
await fs.writeFile(this.lockFile, lockData, { flag: 'wx' });
return true;
} catch (error) {
if (error.code === 'EEXIST') {
// Lock exists - check if stale
const isStale = await this._isLockStale();
if (isStale) {
// Remove stale lock and retry
await this._releaseLock();
continue;
}
// Wait briefly and retry
await new Promise(resolve => setTimeout(resolve, 50));
continue;
}
// Other error (e.g., ENOENT for missing directory) - skip locking
return false;
}
}
// Timeout - could not acquire lock
return false;
}
/**
* Check if the current lock file is stale.
*
* @returns {Promise<boolean>} true if lock is stale
* @private
*/
async _isLockStale() {
try {
const content = await fs.readFile(this.lockFile, 'utf8');
const lockData = JSON.parse(content);
return (Date.now() - lockData.timestamp) > LOCK_STALE_MS;
} catch (error) {
// Cannot read lock file - consider it stale
return true;
}
}
/**
* Release the lock file.
*
* @returns {Promise<void>}
* @private
*/
async _releaseLock() {
try {
await fs.unlink(this.lockFile);
} catch (error) {
// Lock file already removed or doesn't exist - that's fine
}
}
/**
* ACT-3 Task 2: Save cache with file locking for multi-terminal safety.
*
* Writes to a temporary file first, then renames atomically.
* Uses lock file to prevent concurrent write corruption.
*
* @param {ProjectStatus} status
* @param {string|null} gitFingerprint - Current git state fingerprint
*/
async saveCacheWithLock(status, gitFingerprint) {
const lockAcquired = await this._acquireLock();
try {
// Ensure .aios directory exists
const cacheDir = path.dirname(this.cacheFile);
await fs.mkdir(cacheDir, { recursive: true });
const cache = {
status,
timestamp: Date.now(),
ttl: this.cacheTTL,
gitFingerprint: gitFingerprint || null,
};
const content = yaml.dump(cache);
// Atomic write: write to temp file, then rename
const tempFile = this.cacheFile + '.tmp.' + process.pid;
await fs.writeFile(tempFile, content, 'utf8');
try {
await fs.rename(tempFile, this.cacheFile);
} catch (renameError) {
// On Windows, rename can fail if target exists - fall back to direct write
await fs.writeFile(this.cacheFile, content, 'utf8');
// Clean up temp file
try { await fs.unlink(tempFile); } catch { /* ignore */ }
}
} catch (error) {
// Cache write failure is non-critical, just log
console.warn('Failed to write status cache:', error.message);
} finally {
if (lockAcquired) {
await this._releaseLock();
}
}
}
/**
* Save status to cache file (backward-compatible wrapper)
*
* @param {ProjectStatus} status
*/
async saveCache(status) {
await this.saveCacheWithLock(status, null);
}
/**
* Clear cache file
*/
async clearCache() {
try {
await fs.unlink(this.cacheFile);
return true;
} catch (error) {
return false;
}
}
/**
* Get default status for non-git projects
*
* @returns {ProjectStatus}
*/
getNonGitStatus() {
return {
branch: null,
modifiedFiles: [],
recentCommits: [],
currentEpic: null,
currentStory: null,
lastUpdate: new Date().toISOString(),
isGitRepo: false,
};
}
/**
* Get default status on error
*
* @returns {ProjectStatus}
*/
getDefaultStatus() {
return {
branch: 'unknown',
modifiedFiles: [],
recentCommits: [],
currentEpic: null,
currentStory: null,
lastUpdate: new Date().toISOString(),
isGitRepo: false,
};
}
/**
* Format status for display in agent greeting
*
* @param {ProjectStatus} status
* @returns {string}
*/
formatStatusDisplay(status) {
if (!status.isGitRepo) {
return ' (Not a git repository)';
}
const lines = [];
if (status.branch) {
lines.push(` - Branch: ${status.branch}`);
}
if (status.modifiedFiles && status.modifiedFiles.length > 0) {
let filesDisplay = status.modifiedFiles.join(', ');
// QA Fix: Issue 6.1.2.4-I3 - Add truncation message
const totalCount = status.modifiedFilesTotalCount || status.modifiedFiles.length;
if (totalCount > status.modifiedFiles.length) {
const remaining = totalCount - status.modifiedFiles.length;
filesDisplay += ` ...and ${remaining} more`;
}
lines.push(` - Modified: ${filesDisplay}`);
}
if (status.recentCommits && status.recentCommits.length > 0) {
lines.push(` - Recent: ${status.recentCommits.join(', ')}`);
}
if (status.currentStory) {
lines.push(` - Story: ${status.currentStory}`);
}
// Story 1.5: Display worktrees status
if (status.worktrees && Object.keys(status.worktrees).length > 0) {
const worktreeCount = Object.keys(status.worktrees).length;
const activeCount = Object.values(status.worktrees).filter(
(w) => w.status === 'active'
).length;
const withChanges = Object.values(status.worktrees).filter(
(w) => w.uncommittedChanges > 0
).length;
let worktreeInfo = `${activeCount}/${worktreeCount} active`;
if (withChanges > 0) {
worktreeInfo += `, ${withChanges} with changes`;
}
lines.push(` - Worktrees: ${worktreeInfo}`);
}
if (lines.length === 0) {
return ' (No recent activity)';
}
return lines.join('\n');
}
}
/**
* @typedef {Object} WorktreeStatusInfo
* @property {string} path - Relative path to worktree (.aios/worktrees/{story-id})
* @property {string} branch - Branch name (auto-claude/{story-id})
* @property {string} createdAt - ISO timestamp when worktree was created
* @property {string} lastActivity - ISO timestamp of last activity check
* @property {number} uncommittedChanges - Number of uncommitted changes
* @property {'active'|'stale'} status - Worktree status (stale if > 30 days)
*/
/**
* @typedef {Object} ProjectStatus
* @property {string|null} branch - Current git branch
* @property {string[]} modifiedFiles - List of modified files (max 5)
* @property {string[]} recentCommits - Recent commit messages (max 2)
* @property {string|null} currentEpic - Current epic name
* @property {string|null} currentStory - Current story ID
* @property {string} lastUpdate - ISO timestamp of last update
* @property {boolean} isGitRepo - Whether this is a git repository
* @property {Object<string, WorktreeStatusInfo>} [worktrees] - Worktrees status by story ID (Story 1.5)
*/
// Export singleton instance
const loader = new ProjectStatusLoader();
module.exports = {
loadProjectStatus: () => loader.loadProjectStatus(),
clearCache: () => loader.clearCache(),
formatStatusDisplay: (status) => loader.formatStatusDisplay(status),
ProjectStatusLoader,
// ACT-3: Export constants for testing
LOCK_TIMEOUT_MS,
LOCK_STALE_MS,
ACTIVE_SESSION_TTL,
IDLE_TTL,
};