mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-13 02:41:50 +08:00
feat: Add CLAUDE.md freshness tracking and update reminders
- Add SQLite table and CRUD methods for tracking update history - Create freshness calculation service based on git file changes - Add API endpoints for freshness data, marking updates, and history - Display freshness badges in file tree (green/yellow/red indicators) - Show freshness gauge and details in metadata panel - Auto-mark files as updated after CLI sync - Add English and Chinese i18n translations Freshness algorithm: 100 - min((changedFilesCount / 20) * 100, 100) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
319
ccw/src/core/claude-freshness.ts
Normal file
319
ccw/src/core/claude-freshness.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* CLAUDE.md Freshness Calculator
|
||||
* Calculates freshness scores based on git changes since last update
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, statSync, readdirSync } from 'fs';
|
||||
import { dirname, extname, relative, join } from 'path';
|
||||
import { getCoreMemoryStore, ClaudeUpdateRecord } from './core-memory-store.js';
|
||||
|
||||
// Source file extensions to track (from detect-changed-modules.ts)
|
||||
const SOURCE_EXTENSIONS = [
|
||||
'.md', '.js', '.ts', '.jsx', '.tsx',
|
||||
'.py', '.go', '.rs', '.java', '.cpp', '.c', '.h',
|
||||
'.sh', '.ps1', '.json', '.yaml', '.yml'
|
||||
];
|
||||
|
||||
// Directories to exclude
|
||||
const EXCLUDE_DIRS = [
|
||||
'.git', '__pycache__', 'node_modules', '.venv', 'venv', 'env',
|
||||
'dist', 'build', '.cache', '.pytest_cache', '.mypy_cache',
|
||||
'coverage', '.nyc_output', 'logs', 'tmp', 'temp', '.ccw', '.workflow'
|
||||
];
|
||||
|
||||
export interface FreshnessResult {
|
||||
path: string;
|
||||
level: 'user' | 'project' | 'module';
|
||||
relativePath: string;
|
||||
parentDirectory?: string;
|
||||
lastUpdated: string | null;
|
||||
lastModified: string;
|
||||
changedFilesCount: number;
|
||||
freshness: number;
|
||||
updateSource?: string;
|
||||
needsUpdate: boolean;
|
||||
changedFiles?: string[];
|
||||
}
|
||||
|
||||
export interface FreshnessSummary {
|
||||
totalFiles: number;
|
||||
staleCount: number;
|
||||
averageFreshness: number;
|
||||
lastScanAt: string;
|
||||
}
|
||||
|
||||
export interface FreshnessResponse {
|
||||
files: FreshnessResult[];
|
||||
summary: FreshnessSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if git is available and we're in a repo
|
||||
*/
|
||||
function isGitRepo(basePath: string): boolean {
|
||||
try {
|
||||
execSync('git rev-parse --git-dir', { cwd: basePath, stdio: 'pipe' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current git commit hash
|
||||
*/
|
||||
export function getCurrentGitCommit(basePath: string): string | null {
|
||||
try {
|
||||
const output = execSync('git rev-parse HEAD', {
|
||||
cwd: basePath,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}).trim();
|
||||
return output || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get files changed since a specific date within a directory
|
||||
*/
|
||||
function getChangedFilesSince(basePath: string, modulePath: string, sinceDate: string): string[] {
|
||||
try {
|
||||
// Format date for git
|
||||
const date = new Date(sinceDate);
|
||||
const formattedDate = date.toISOString().split('T')[0];
|
||||
|
||||
// Get files changed since the date
|
||||
const output = execSync(
|
||||
`git log --name-only --since="${formattedDate}" --pretty=format: -- "${modulePath}"`,
|
||||
{
|
||||
cwd: basePath,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}
|
||||
).trim();
|
||||
|
||||
if (!output) return [];
|
||||
|
||||
// Get unique files and filter by source extensions
|
||||
const files = [...new Set(output.split('\n').filter(f => f.trim()))];
|
||||
return files.filter(f => {
|
||||
const ext = extname(f).toLowerCase();
|
||||
return SOURCE_EXTENSIONS.includes(ext);
|
||||
});
|
||||
} catch (e) {
|
||||
// Fallback to mtime-based detection
|
||||
return findFilesModifiedSince(modulePath, sinceDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Find files modified since a date using mtime
|
||||
*/
|
||||
function findFilesModifiedSince(dirPath: string, sinceDate: string): string[] {
|
||||
const results: string[] = [];
|
||||
const cutoffTime = new Date(sinceDate).getTime();
|
||||
|
||||
function scan(currentPath: string): void {
|
||||
try {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (EXCLUDE_DIRS.includes(entry.name)) continue;
|
||||
scan(join(currentPath, entry.name));
|
||||
} else if (entry.isFile()) {
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (!SOURCE_EXTENSIONS.includes(ext)) continue;
|
||||
|
||||
const fullPath = join(currentPath, entry.name);
|
||||
try {
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.mtimeMs > cutoffTime) {
|
||||
results.push(relative(dirPath, fullPath));
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore permission errors
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(dirPath)) {
|
||||
scan(dirPath);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate freshness for a single CLAUDE.md file
|
||||
*/
|
||||
export function calculateFreshness(
|
||||
filePath: string,
|
||||
fileLevel: 'user' | 'project' | 'module',
|
||||
lastUpdateTime: string | null,
|
||||
lastModified: string,
|
||||
projectPath: string,
|
||||
threshold: number = 20
|
||||
): FreshnessResult {
|
||||
// Use lastUpdateTime from history, or fall back to file mtime
|
||||
const effectiveUpdateTime = lastUpdateTime || lastModified;
|
||||
|
||||
// Calculate module path for change detection
|
||||
let modulePath: string | null = null;
|
||||
let changedFiles: string[] = [];
|
||||
|
||||
if (fileLevel === 'module') {
|
||||
// For module-level files, scan the parent directory
|
||||
modulePath = dirname(filePath);
|
||||
} else if (fileLevel === 'project') {
|
||||
// For project-level files, scan the project root
|
||||
modulePath = projectPath;
|
||||
}
|
||||
|
||||
// Only calculate changes for module/project level in git repos
|
||||
if (modulePath && isGitRepo(projectPath)) {
|
||||
changedFiles = getChangedFilesSince(projectPath, modulePath, effectiveUpdateTime);
|
||||
// Exclude the CLAUDE.md file itself
|
||||
changedFiles = changedFiles.filter(f => !f.endsWith('CLAUDE.md'));
|
||||
}
|
||||
|
||||
// Calculate freshness percentage
|
||||
const changedCount = changedFiles.length;
|
||||
const freshness = Math.max(0, 100 - Math.floor((changedCount / threshold) * 100));
|
||||
|
||||
// Determine parent directory for display
|
||||
const parentDirectory = fileLevel === 'module'
|
||||
? filePath.split(/[\\/]/).slice(-2, -1)[0]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
level: fileLevel,
|
||||
relativePath: relative(projectPath, filePath).replace(/\\/g, '/'),
|
||||
parentDirectory,
|
||||
lastUpdated: lastUpdateTime,
|
||||
lastModified,
|
||||
changedFilesCount: changedCount,
|
||||
freshness,
|
||||
needsUpdate: freshness < 50,
|
||||
changedFiles: changedFiles.slice(0, 20) // Limit to first 20 for detail view
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate freshness for all CLAUDE.md files in a project
|
||||
*/
|
||||
export function calculateAllFreshness(
|
||||
claudeFiles: Array<{
|
||||
path: string;
|
||||
level: 'user' | 'project' | 'module';
|
||||
lastModified: string;
|
||||
}>,
|
||||
projectPath: string,
|
||||
threshold: number = 20
|
||||
): FreshnessResponse {
|
||||
// Get update records from store
|
||||
const store = getCoreMemoryStore(projectPath);
|
||||
const updateRecords = store.getAllClaudeUpdateRecords();
|
||||
|
||||
// Create a map for quick lookup
|
||||
const updateMap = new Map<string, ClaudeUpdateRecord>();
|
||||
for (const record of updateRecords) {
|
||||
updateMap.set(record.file_path, record);
|
||||
}
|
||||
|
||||
const results: FreshnessResult[] = [];
|
||||
|
||||
for (const file of claudeFiles) {
|
||||
const updateRecord = updateMap.get(file.path);
|
||||
|
||||
const result = calculateFreshness(
|
||||
file.path,
|
||||
file.level,
|
||||
updateRecord?.updated_at || null,
|
||||
file.lastModified,
|
||||
projectPath,
|
||||
threshold
|
||||
);
|
||||
|
||||
if (updateRecord) {
|
||||
result.updateSource = updateRecord.update_source;
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
const staleCount = results.filter(r => r.needsUpdate).length;
|
||||
const totalFreshness = results.reduce((sum, r) => sum + r.freshness, 0);
|
||||
const averageFreshness = results.length > 0 ? Math.round(totalFreshness / results.length) : 100;
|
||||
|
||||
return {
|
||||
files: results,
|
||||
summary: {
|
||||
totalFiles: results.length,
|
||||
staleCount,
|
||||
averageFreshness,
|
||||
lastScanAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a CLAUDE.md file as updated
|
||||
*/
|
||||
export function markFileAsUpdated(
|
||||
filePath: string,
|
||||
fileLevel: 'user' | 'project' | 'module',
|
||||
updateSource: 'manual' | 'cli_sync' | 'dashboard' | 'api',
|
||||
projectPath: string,
|
||||
metadata?: object
|
||||
): ClaudeUpdateRecord {
|
||||
const store = getCoreMemoryStore(projectPath);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Get current git commit
|
||||
const gitCommit = getCurrentGitCommit(projectPath);
|
||||
|
||||
// Calculate changed files count before this update
|
||||
const lastUpdate = store.getLastClaudeUpdate(filePath);
|
||||
let filesChangedCount = 0;
|
||||
|
||||
if (lastUpdate && isGitRepo(projectPath)) {
|
||||
const modulePath = fileLevel === 'module' ? dirname(filePath) : projectPath;
|
||||
const changedFiles = getChangedFilesSince(projectPath, modulePath, lastUpdate.updated_at);
|
||||
filesChangedCount = changedFiles.filter(f => !f.endsWith('CLAUDE.md')).length;
|
||||
}
|
||||
|
||||
// Insert update record
|
||||
const record = store.insertClaudeUpdateRecord({
|
||||
file_path: filePath,
|
||||
file_level: fileLevel,
|
||||
module_path: fileLevel === 'module' ? dirname(filePath) : undefined,
|
||||
updated_at: now,
|
||||
update_source: updateSource,
|
||||
git_commit_hash: gitCommit || undefined,
|
||||
files_changed_before_update: filesChangedCount,
|
||||
metadata: metadata ? JSON.stringify(metadata) : undefined
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get update history for a file
|
||||
*/
|
||||
export function getUpdateHistory(
|
||||
filePath: string,
|
||||
projectPath: string,
|
||||
limit: number = 50
|
||||
): ClaudeUpdateRecord[] {
|
||||
const store = getCoreMemoryStore(projectPath);
|
||||
return store.getClaudeUpdateHistory(filePath, limit);
|
||||
}
|
||||
Reference in New Issue
Block a user