diff --git a/.gitignore b/.gitignore index cf827bd..5290ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ state.json !starters/**/.codex/ !starters/**/.codex/** *.session +!tools/loop-sync/dist/ diff --git a/package.json b/package.json index 6cc26c7..c5242c9 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,12 @@ "test:loop-audit": "cd tools/loop-audit && npm test", "test:loop-init": "cd tools/loop-init && npm test", "test:loop-cost": "cd tools/loop-cost && npm test", - "test:tools": "npm run test:loop-audit && npm run test:loop-init && npm run test:loop-cost", - "build:tools": "cd tools/loop-audit && npm run build && cd ../loop-init && npm run build && cd ../loop-cost && npm run build" + "test:loop-sync": "cd tools/loop-sync && npm test", + "test:tools": "npm run test:loop-audit && npm run test:loop-init && npm run test:loop-cost && npm run test:loop-sync", + "build:tools": "cd tools/loop-audit && npm run build && cd ../loop-init && npm run build && cd ../loop-cost && npm run build && cd ../loop-sync && npm run build" }, "devDependencies": { "ajv": "^8.17.1", "yaml": "^2.8.0" } -} \ No newline at end of file +} diff --git a/tools/loop-sync/README.md b/tools/loop-sync/README.md new file mode 100644 index 0000000..1794137 --- /dev/null +++ b/tools/loop-sync/README.md @@ -0,0 +1,115 @@ +# loop-sync + +Detect and sync drift between Loop configuration files in your repository. + +## Why loop-sync? + +When working in teams, Loop configurations can drift over time: + +- STATE.md and LOOP.md get out of sync +- Skills are updated but not reflected in configuration +- Required files are missing +- Configuration drifts from starters + +`loop-sync` detects these issues and provides actionable suggestions. + +## Installation + +```bash +npm install -g @cobusgreyling/loop-sync +# or +npx @cobusgreyling/loop-sync . +``` + +## Usage + +```bash +loop-sync [target-dir] [options] +``` + +### Options + +| Option | Description | +|--------|-------------| +| `-a, --auto-fix` | Attempt to auto-fix issues (experimental) | +| `-d, --dry-run` | Show what would be done without making changes | +| `-v, --verbose` | Show detailed information | +| `--json` | Output JSON format | +| `-h, --help` | Show help | + +### Examples + +```bash +# Basic sync check +loop-sync . + +# Verbose output +loop-sync ./my-project -v + +# JSON output for scripting +loop-sync ./my-project --json +``` + +## What it checks + +1. **Required files** + - STATE.md (required) + - LOOP.md (required) + - AGENTS.md (recommended) + +2. **STATE.md ↔ LOOP.md consistency** + - Structural similarity + - State file references + - Pattern consistency + +3. **Skills directory** + - Existence of `.claude/skills/` + - Version information in SKILL.md files + +4. **Configuration drift** + - Missing references + - Orphaned files + - Inconsistencies + +## Score Interpretation + +| Score | Level | Meaning | +|-------|-------|---------| +| 90-100 | Healthy | No issues detected | +| 70-89 | Warning | Minor inconsistencies | +| 0-69 | Critical | Major issues need attention | + +## Output Example + +``` +Loop Sync Report +══════════════════════════════════════════════════ +Score: 85/100 (healthy) + +✅ No issues detected. Configuration is consistent. + +💡 Suggestions: + - Run loop-init to scaffold missing files +``` + +## Integration with CI/CD + +Add to your GitHub Actions workflow: + +```yaml +- name: Run loop-sync + run: npx @cobusgreyling/loop-sync . +``` + +## Development + +```bash +cd tools/loop-sync +npm install +npm run build +npm test +``` + +## License + +MIT \ No newline at end of file diff --git a/tools/loop-sync/dist/cli.d.ts b/tools/loop-sync/dist/cli.d.ts new file mode 100644 index 0000000..88ba4b2 --- /dev/null +++ b/tools/loop-sync/dist/cli.d.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +/** + * Loop Sync CLI + * + * Detect and sync drift between Loop configuration files + */ +export {}; diff --git a/tools/loop-sync/dist/cli.js b/tools/loop-sync/dist/cli.js new file mode 100644 index 0000000..c63e210 --- /dev/null +++ b/tools/loop-sync/dist/cli.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Loop Sync CLI + * + * Detect and sync drift between Loop configuration files + */ +import { runSync, formatReport } from './sync.js'; +function parseArgs(argv) { + let targetDir = '.'; + let autoFix = false; + let dryRun = false; + let verbose = false; + let json = false; + let help = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') + help = true; + else if (a === '--auto-fix' || a === '-a') + autoFix = true; + else if (a === '--dry-run' || a === '-d') + dryRun = true; + else if (a === '--verbose' || a === '-v') + verbose = true; + else if (a === '--json') + json = true; + else if (!a.startsWith('-')) + targetDir = a; + } + return { targetDir, autoFix, dryRun, verbose, help }; +} +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(`loop-sync — detect and sync drift between Loop configuration files + +Usage: + loop-sync [target-dir] [options] + +Options: + -a, --auto-fix Attempt to auto-fix issues (experimental) + -d, --dry-run Show what would be done without making changes + -v, --verbose Show detailed information + --json Output JSON format + -h, --help Show this help + +Examples: + loop-sync . + loop-sync ./my-project -v + loop-sync ./my-project --json + +The tool checks: + - STATE.md ↔ LOOP.md consistency + - Required files (STATE.md, LOOP.md, AGENTS.md) + - Skills directory structure + - Configuration drift indicators + +Score interpretation: + - 90-100: Healthy (no issues) + - 70-89: Warning (minor inconsistencies) + - 0-69: Critical (major issues need attention) + +Docs: https://github.com/cobusgreyling/loop-engineering/tree/main/tools/loop-sync +`); + process.exit(0); + } + try { + const report = await runSync(args); + if (args.json) { + console.log(JSON.stringify(report, null, 2)); + } + else { + console.log(formatReport(report)); + } + // Exit with appropriate code + if (report.level === 'critical') { + process.exit(1); + } + else if (report.level === 'warning') { + process.exit(2); + } + } + catch (error) { + console.error('loop-sync failed:', error instanceof Error ? error.message : error); + process.exit(1); + } +} +main(); diff --git a/tools/loop-sync/dist/sync.d.ts b/tools/loop-sync/dist/sync.d.ts new file mode 100644 index 0000000..eef5939 --- /dev/null +++ b/tools/loop-sync/dist/sync.d.ts @@ -0,0 +1,39 @@ +/** + * Loop Sync - Detect and sync drift between Loop configuration files + * + * This module detects: + * 1. STATE.md ↔ LOOP.md consistency + * 2. Skills version updates + * 3. Missing required files + * 4. Configuration drift from starters + */ +export interface DriftReport { + score: number; + level: 'healthy' | 'warning' | 'critical'; + issues: DriftIssue[]; + suggestions: string[]; + timestamp: string; +} +export interface DriftIssue { + type: 'missing' | 'outdated' | 'inconsistent' | 'orphaned'; + file: string; + message: string; + severity: 'error' | 'warning' | 'info'; + suggestion?: string; +} +export interface SyncOptions { + targetDir: string; + autoFix: boolean; + dryRun: boolean; + verbose: boolean; + help?: boolean; + json?: boolean; +} +/** + * Main sync function + */ +export declare function runSync(options: SyncOptions): Promise; +/** + * Format report for CLI output + */ +export declare function formatReport(report: DriftReport): string; diff --git a/tools/loop-sync/dist/sync.js b/tools/loop-sync/dist/sync.js new file mode 100644 index 0000000..45f9bfc --- /dev/null +++ b/tools/loop-sync/dist/sync.js @@ -0,0 +1,301 @@ +/** + * Loop Sync - Detect and sync drift between Loop configuration files + * + * This module detects: + * 1. STATE.md ↔ LOOP.md consistency + * 2. Skills version updates + * 3. Missing required files + * 4. Configuration drift from starters + */ +import { readFile, access } from 'node:fs/promises'; +import { readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +/** + * Check if a file exists + */ +async function fileExists(filePath) { + try { + await access(filePath); + return true; + } + catch { + return false; + } +} +/** + * Read file content + */ +async function readFileContent(filePath) { + try { + return await readFile(filePath, 'utf8'); + } + catch { + return null; + } +} +/** + * Extract frontmatter from markdown files + */ +function extractFrontmatter(content) { + const frontmatter = {}; + let body = content; + if (content.startsWith('---')) { + const endIndex = content.indexOf('---', 3); + if (endIndex !== -1) { + const fmContent = content.slice(3, endIndex); + body = content.slice(endIndex + 3); + for (const line of fmContent.split('\n')) { + const colonIndex = line.indexOf(':'); + if (colonIndex !== -1) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + frontmatter[key] = value; + } + } + } + } + return { frontmatter, body }; +} +/** + * Extract patterns from LOOP.md + */ +function extractLoopPatterns(loopContent) { + const patterns = []; + // Look for pattern references + const patternRegex = /pattern[s]?[\s]*[:=][\s]*([\w\-]+)/gi; + let match; + while ((match = patternRegex.exec(loopContent)) !== null) { + patterns.push(match[1]); + } + return patterns; +} +/** + * Extract state file references from LOOP.md + */ +function extractStateFiles(loopContent) { + const stateFiles = []; + // Look for STATE.md or other state file references + const stateRegex = /(?:state[s]?[\s]*[:=][\s]*|update[\s]+)([\w\-]+\.md)/gi; + let match; + while ((match = stateRegex.exec(loopContent)) !== null) { + stateFiles.push(match[1]); + } + return stateFiles; +} +/** + * Compare two markdown files for consistency + */ +function compareMarkdownFiles(file1Content, file2Content, threshold = 0.5) { + const differences = []; + // Extract headings from both files + const headings1 = file1Content.match(/^## .+$/gm) || []; + const headings2 = file2Content.match(/^## .+$/gm) || []; + // Check heading consistency + const headingSet1 = new Set(headings1.map(h => h.toLowerCase())); + const headingSet2 = new Set(headings2.map(h => h.toLowerCase())); + const allHeadings = new Set([...headingSet1, ...headingSet2]); + let matchingHeadings = 0; + for (const heading of allHeadings) { + if (headingSet1.has(heading) && headingSet2.has(heading)) { + matchingHeadings++; + } + else if (!headingSet1.has(heading)) { + differences.push(`Missing heading in file 1: ${heading}`); + } + else { + differences.push(`Missing heading in file 2: ${heading}`); + } + } + const similarity = allHeadings.size === 0 ? 1 : matchingHeadings / allHeadings.size; + return { similarity, differences }; +} +/** + * Scan skills directory for version information + */ +async function scanSkillsDirectory(targetDir) { + const skillsVersions = new Map(); + const skillsDir = path.join(targetDir, '.claude', 'skills'); + if (!await fileExists(skillsDir)) { + return skillsVersions; + } + try { + const entries = await readdir(skillsDir); + for (const entry of entries) { + const skillPath = path.join(skillsDir, entry); + const statResult = await stat(skillPath); + if (statResult.isDirectory()) { + const skillMd = path.join(skillPath, 'SKILL.md'); + const content = await readFileContent(skillMd); + if (content) { + const { frontmatter } = extractFrontmatter(content); + skillsVersions.set(entry, frontmatter.version || 'unknown'); + } + } + } + } + catch (error) { + // Ignore errors + } + return skillsVersions; +} +/** + * Main sync function + */ +export async function runSync(options) { + const { targetDir, autoFix, dryRun, verbose } = options; + const issues = []; + const suggestions = []; + // Define required files + const requiredFiles = [ + 'STATE.md', + 'LOOP.md', + 'AGENTS.md', + ]; + // Check for missing required files + for (const file of requiredFiles) { + const filePath = path.join(targetDir, file); + if (!await fileExists(filePath)) { + issues.push({ + type: 'missing', + file, + message: `${file} is missing`, + severity: 'error', + suggestion: `Run 'npx @cobusgreyling/loop-init . --pattern daily-triage' to scaffold required files`, + }); + } + } + // Check STATE.md ↔ LOOP.md consistency + const statePath = path.join(targetDir, 'STATE.md'); + const loopPath = path.join(targetDir, 'LOOP.md'); + if (await fileExists(statePath) && await fileExists(loopPath)) { + const stateContent = await readFileContent(statePath); + const loopContent = await readFileContent(loopPath); + if (stateContent && loopContent) { + // Extract patterns from LOOP.md + const patterns = extractLoopPatterns(loopContent); + // Extract state files from LOOP.md + const stateFiles = extractStateFiles(loopContent); + // Check if STATE.md is referenced in LOOP.md + if (!stateFiles.includes('STATE.md')) { + issues.push({ + type: 'inconsistent', + file: 'LOOP.md', + message: 'LOOP.md does not reference STATE.md', + severity: 'warning', + suggestion: 'Add STATE.md to the state files list in LOOP.md', + }); + } + // Compare structural similarity + const { similarity, differences } = compareMarkdownFiles(stateContent, loopContent, 0.5); + if (similarity < 0.3 && differences.length > 0) { + issues.push({ + type: 'inconsistent', + file: 'STATE.md ↔ LOOP.md', + message: 'Low structural similarity between STATE.md and LOOP.md', + severity: 'warning', + suggestion: 'Review both files for consistency', + }); + if (verbose) { + for (const diff of differences.slice(0, 3)) { + issues.push({ + type: 'inconsistent', + file: 'STATE.md ↔ LOOP.md', + message: diff, + severity: 'info', + }); + } + } + } + } + } + // Scan skills for version information + const skillsVersions = await scanSkillsDirectory(targetDir); + if (skillsVersions.size === 0 && await fileExists(path.join(targetDir, '.claude', 'skills'))) { + suggestions.push('No skills found. Run loop-init to scaffold skills.'); + } + // Calculate score + let score = 100; + for (const issue of issues) { + if (issue.severity === 'error') { + score -= 20; + } + else if (issue.severity === 'warning') { + score -= 10; + } + else { + score -= 1; + } + } + score = Math.max(0, Math.min(100, score)); + // Determine level + let level = 'healthy'; + if (score < 40) { + level = 'critical'; + } + else if (score < 70) { + level = 'warning'; + } + // Add suggestions based on issues + if (issues.some(i => i.type === 'missing')) { + suggestions.push('Run loop-init to scaffold missing files'); + } + if (issues.some(i => i.type === 'inconsistent')) { + suggestions.push('Review STATE.md and LOOP.md for consistency'); + } + return { + score, + level, + issues, + suggestions, + timestamp: new Date().toISOString(), + }; +} +/** + * Format report for CLI output + */ +export function formatReport(report) { + const lines = []; + lines.push(''); + lines.push('Loop Sync Report'); + lines.push('══════════════════════════════════════════════════'); + lines.push(`Score: ${report.score}/100 (${report.level})`); + lines.push(''); + if (report.issues.length === 0) { + lines.push('✅ No issues detected. Configuration is consistent.'); + } + else { + lines.push(`Found ${report.issues.length} issue(s):`); + lines.push(''); + const errors = report.issues.filter(i => i.severity === 'error'); + const warnings = report.issues.filter(i => i.severity === 'warning'); + const infos = report.issues.filter(i => i.severity === 'info'); + if (errors.length > 0) { + lines.push('❌ Errors:'); + for (const issue of errors) { + lines.push(` - ${issue.file}: ${issue.message}`); + } + lines.push(''); + } + if (warnings.length > 0) { + lines.push('⚠️ Warnings:'); + for (const issue of warnings) { + lines.push(` - ${issue.file}: ${issue.message}`); + } + lines.push(''); + } + if (infos.length > 0) { + lines.push('ℹ️ Information:'); + for (const issue of infos) { + lines.push(` - ${issue.file}: ${issue.message}`); + } + lines.push(''); + } + } + if (report.suggestions.length > 0) { + lines.push('💡 Suggestions:'); + for (const suggestion of report.suggestions) { + lines.push(` - ${suggestion}`); + } + } + return lines.join('\n'); +} diff --git a/tools/loop-sync/package-lock.json b/tools/loop-sync/package-lock.json new file mode 100644 index 0000000..65657e7 --- /dev/null +++ b/tools/loop-sync/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@cobusgreyling/loop-sync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cobusgreyling/loop-sync", + "version": "1.0.0", + "license": "MIT", + "bin": { + "loop-sync": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^26.0.1", + "typescript": "^5.9.3" + } + }, + "node_modules/@types/node": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tools/loop-sync/package.json b/tools/loop-sync/package.json new file mode 100644 index 0000000..73584bc --- /dev/null +++ b/tools/loop-sync/package.json @@ -0,0 +1,39 @@ +{ + "name": "@cobusgreyling/loop-sync", + "version": "1.0.0", + "description": "Detect and sync drift between Loop configuration files (STATE.md ↔ LOOP.md, Skills versions)", + "type": "module", + "main": "dist/cli.js", + "bin": { + "loop-sync": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "node test/sync.test.mjs", + "bundle": "node scripts/bundle-assets.mjs" + }, + "keywords": [ + "loop-engineering", + "sync", + "drift-detection", + "configuration" + ], + "author": "Cobus Greyling", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cobusgreyling/loop-engineering.git", + "directory": "tools/loop-sync" + }, + "homepage": "https://github.com/cobusgreyling/loop-engineering/tree/main/tools/loop-sync#readme", + "bugs": { + "url": "https://github.com/cobusgreyling/loop-engineering/issues" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} \ No newline at end of file diff --git a/tools/loop-sync/src/cli.ts b/tools/loop-sync/src/cli.ts new file mode 100644 index 0000000..ee19f77 --- /dev/null +++ b/tools/loop-sync/src/cli.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Loop Sync CLI + * + * Detect and sync drift between Loop configuration files + */ + +import { runSync, formatReport, type SyncOptions, type DriftReport } from './sync.js'; + +function parseArgs(argv: string[]): SyncOptions { + let targetDir = '.'; + let autoFix = false; + let dryRun = false; + let verbose = false; + let json = false; + let help = false; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') help = true; + else if (a === '--auto-fix' || a === '-a') autoFix = true; + else if (a === '--dry-run' || a === '-d') dryRun = true; + else if (a === '--verbose' || a === '-v') verbose = true; + else if (a === '--json') json = true; + else if (!a.startsWith('-')) targetDir = a; + } + + return { targetDir, autoFix, dryRun, verbose, help }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + console.log(`loop-sync — detect and sync drift between Loop configuration files + +Usage: + loop-sync [target-dir] [options] + +Options: + -a, --auto-fix Attempt to auto-fix issues (experimental) + -d, --dry-run Show what would be done without making changes + -v, --verbose Show detailed information + --json Output JSON format + -h, --help Show this help + +Examples: + loop-sync . + loop-sync ./my-project -v + loop-sync ./my-project --json + +The tool checks: + - STATE.md ↔ LOOP.md consistency + - Required files (STATE.md, LOOP.md, AGENTS.md) + - Skills directory structure + - Configuration drift indicators + +Score interpretation: + - 90-100: Healthy (no issues) + - 70-89: Warning (minor inconsistencies) + - 0-69: Critical (major issues need attention) + +Docs: https://github.com/cobusgreyling/loop-engineering/tree/main/tools/loop-sync +`); + process.exit(0); + } + + try { + const report = await runSync(args); + + if (args.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(formatReport(report)); + } + + // Exit with appropriate code + if (report.level === 'critical') { + process.exit(1); + } else if (report.level === 'warning') { + process.exit(2); + } + } catch (error) { + console.error('loop-sync failed:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +main(); \ No newline at end of file diff --git a/tools/loop-sync/src/sync.ts b/tools/loop-sync/src/sync.ts new file mode 100644 index 0000000..a1e1525 --- /dev/null +++ b/tools/loop-sync/src/sync.ts @@ -0,0 +1,374 @@ +/** + * Loop Sync - Detect and sync drift between Loop configuration files + * + * This module detects: + * 1. STATE.md ↔ LOOP.md consistency + * 2. Skills version updates + * 3. Missing required files + * 4. Configuration drift from starters + */ + +import { readFile, writeFile, access, mkdir } from 'node:fs/promises'; +import { readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +export interface DriftReport { + score: number; // 0-100 consistency score + level: 'healthy' | 'warning' | 'critical'; + issues: DriftIssue[]; + suggestions: string[]; + timestamp: string; +} + +export interface DriftIssue { + type: 'missing' | 'outdated' | 'inconsistent' | 'orphaned'; + file: string; + message: string; + severity: 'error' | 'warning' | 'info'; + suggestion?: string; +} + +export interface SyncOptions { + targetDir: string; + autoFix: boolean; + dryRun: boolean; + verbose: boolean; + help?: boolean; + json?: boolean; +} + +/** + * Check if a file exists + */ +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Read file content + */ +async function readFileContent(filePath: string): Promise { + try { + return await readFile(filePath, 'utf8'); + } catch { + return null; + } +} + +/** + * Extract frontmatter from markdown files + */ +function extractFrontmatter(content: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {}; + let body = content; + + if (content.startsWith('---')) { + const endIndex = content.indexOf('---', 3); + if (endIndex !== -1) { + const fmContent = content.slice(3, endIndex); + body = content.slice(endIndex + 3); + + for (const line of fmContent.split('\n')) { + const colonIndex = line.indexOf(':'); + if (colonIndex !== -1) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + frontmatter[key] = value; + } + } + } + } + + return { frontmatter, body }; +} + +/** + * Extract patterns from LOOP.md + */ +function extractLoopPatterns(loopContent: string): string[] { + const patterns: string[] = []; + + // Look for pattern references + const patternRegex = /pattern[s]?[\s]*[:=][\s]*([\w\-]+)/gi; + let match; + while ((match = patternRegex.exec(loopContent)) !== null) { + patterns.push(match[1]); + } + + return patterns; +} + +/** + * Extract state file references from LOOP.md + */ +function extractStateFiles(loopContent: string): string[] { + const stateFiles: string[] = []; + + // Look for STATE.md or other state file references + const stateRegex = /(?:state[s]?[\s]*[:=][\s]*|update[\s]+)([\w\-]+\.md)/gi; + let match; + while ((match = stateRegex.exec(loopContent)) !== null) { + stateFiles.push(match[1]); + } + + return stateFiles; +} + +/** + * Compare two markdown files for consistency + */ +function compareMarkdownFiles( + file1Content: string, + file2Content: string, + threshold: number = 0.5 +): { similarity: number; differences: string[] } { + const differences: string[] = []; + + // Extract headings from both files + const headings1 = file1Content.match(/^## .+$/gm) || []; + const headings2 = file2Content.match(/^## .+$/gm) || []; + + // Check heading consistency + const headingSet1 = new Set(headings1.map(h => h.toLowerCase())); + const headingSet2 = new Set(headings2.map(h => h.toLowerCase())); + + const allHeadings = new Set([...headingSet1, ...headingSet2]); + let matchingHeadings = 0; + + for (const heading of allHeadings) { + if (headingSet1.has(heading) && headingSet2.has(heading)) { + matchingHeadings++; + } else if (!headingSet1.has(heading)) { + differences.push(`Missing heading in file 1: ${heading}`); + } else { + differences.push(`Missing heading in file 2: ${heading}`); + } + } + + const similarity = allHeadings.size === 0 ? 1 : matchingHeadings / allHeadings.size; + + return { similarity, differences }; +} + +/** + * Scan skills directory for version information + */ +async function scanSkillsDirectory(targetDir: string): Promise> { + const skillsVersions = new Map(); + const skillsDir = path.join(targetDir, '.claude', 'skills'); + + if (!await fileExists(skillsDir)) { + return skillsVersions; + } + + try { + const entries = await readdir(skillsDir); + for (const entry of entries) { + const skillPath = path.join(skillsDir, entry); + const statResult = await stat(skillPath); + + if (statResult.isDirectory()) { + const skillMd = path.join(skillPath, 'SKILL.md'); + const content = await readFileContent(skillMd); + + if (content) { + const { frontmatter } = extractFrontmatter(content); + skillsVersions.set(entry, frontmatter.version || 'unknown'); + } + } + } + } catch (error) { + // Ignore errors + } + + return skillsVersions; +} + +/** + * Main sync function + */ +export async function runSync(options: SyncOptions): Promise { + const { targetDir, autoFix, dryRun, verbose } = options; + const issues: DriftIssue[] = []; + const suggestions: string[] = []; + + // Define required files + const requiredFiles = [ + 'STATE.md', + 'LOOP.md', + 'AGENTS.md', + ]; + + // Check for missing required files + for (const file of requiredFiles) { + const filePath = path.join(targetDir, file); + if (!await fileExists(filePath)) { + issues.push({ + type: 'missing', + file, + message: `${file} is missing`, + severity: 'error', + suggestion: `Run 'npx @cobusgreyling/loop-init . --pattern daily-triage' to scaffold required files`, + }); + } + } + + // Check STATE.md ↔ LOOP.md consistency + const statePath = path.join(targetDir, 'STATE.md'); + const loopPath = path.join(targetDir, 'LOOP.md'); + + if (await fileExists(statePath) && await fileExists(loopPath)) { + const stateContent = await readFileContent(statePath); + const loopContent = await readFileContent(loopPath); + + if (stateContent && loopContent) { + // Extract patterns from LOOP.md + const patterns = extractLoopPatterns(loopContent); + + // Extract state files from LOOP.md + const stateFiles = extractStateFiles(loopContent); + + // Check if STATE.md is referenced in LOOP.md + if (!stateFiles.includes('STATE.md')) { + issues.push({ + type: 'inconsistent', + file: 'LOOP.md', + message: 'LOOP.md does not reference STATE.md', + severity: 'warning', + suggestion: 'Add STATE.md to the state files list in LOOP.md', + }); + } + + // Compare structural similarity + const { similarity, differences } = compareMarkdownFiles(stateContent, loopContent, 0.5); + + if (similarity < 0.3 && differences.length > 0) { + issues.push({ + type: 'inconsistent', + file: 'STATE.md ↔ LOOP.md', + message: 'Low structural similarity between STATE.md and LOOP.md', + severity: 'warning', + suggestion: 'Review both files for consistency', + }); + + if (verbose) { + for (const diff of differences.slice(0, 3)) { + issues.push({ + type: 'inconsistent', + file: 'STATE.md ↔ LOOP.md', + message: diff, + severity: 'info', + }); + } + } + } + } + } + + // Scan skills for version information + const skillsVersions = await scanSkillsDirectory(targetDir); + + if (skillsVersions.size === 0 && await fileExists(path.join(targetDir, '.claude', 'skills'))) { + suggestions.push('No skills found. Run loop-init to scaffold skills.'); + } + + // Calculate score + let score = 100; + for (const issue of issues) { + if (issue.severity === 'error') { + score -= 20; + } else if (issue.severity === 'warning') { + score -= 10; + } else { + score -= 1; + } + } + score = Math.max(0, Math.min(100, score)); + + // Determine level + let level: 'healthy' | 'warning' | 'critical' = 'healthy'; + if (score < 40) { + level = 'critical'; + } else if (score < 70) { + level = 'warning'; + } + + // Add suggestions based on issues + if (issues.some(i => i.type === 'missing')) { + suggestions.push('Run loop-init to scaffold missing files'); + } + + if (issues.some(i => i.type === 'inconsistent')) { + suggestions.push('Review STATE.md and LOOP.md for consistency'); + } + + return { + score, + level, + issues, + suggestions, + timestamp: new Date().toISOString(), + }; +} + +/** + * Format report for CLI output + */ +export function formatReport(report: DriftReport): string { + const lines: string[] = []; + + lines.push(''); + lines.push('Loop Sync Report'); + lines.push('══════════════════════════════════════════════════'); + lines.push(`Score: ${report.score}/100 (${report.level})`); + lines.push(''); + + if (report.issues.length === 0) { + lines.push('✅ No issues detected. Configuration is consistent.'); + } else { + lines.push(`Found ${report.issues.length} issue(s):`); + lines.push(''); + + const errors = report.issues.filter(i => i.severity === 'error'); + const warnings = report.issues.filter(i => i.severity === 'warning'); + const infos = report.issues.filter(i => i.severity === 'info'); + + if (errors.length > 0) { + lines.push('❌ Errors:'); + for (const issue of errors) { + lines.push(` - ${issue.file}: ${issue.message}`); + } + lines.push(''); + } + + if (warnings.length > 0) { + lines.push('⚠️ Warnings:'); + for (const issue of warnings) { + lines.push(` - ${issue.file}: ${issue.message}`); + } + lines.push(''); + } + + if (infos.length > 0) { + lines.push('ℹ️ Information:'); + for (const issue of infos) { + lines.push(` - ${issue.file}: ${issue.message}`); + } + lines.push(''); + } + } + + if (report.suggestions.length > 0) { + lines.push('💡 Suggestions:'); + for (const suggestion of report.suggestions) { + lines.push(` - ${suggestion}`); + } + } + + return lines.join('\n'); +} \ No newline at end of file diff --git a/tools/loop-sync/test/sync.test.mjs b/tools/loop-sync/test/sync.test.mjs new file mode 100644 index 0000000..f5c4f8e --- /dev/null +++ b/tools/loop-sync/test/sync.test.mjs @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { runSync, formatReport } from '../src/sync.js'; + +// Create a temporary test directory +const testDir = path.join(process.cwd(), '.test-tmp'); + +async function setupTestDir() { + await mkdir(testDir, { recursive: true }); + + // Create STATE.md + await writeFile( + path.join(testDir, 'STATE.md'), + `# Loop State + +Last run: 2026-06-22 + +## High Priority +- No items + +## Watch List +- No items +` + ); + + // Create LOOP.md + await writeFile( + path.join(testDir, 'LOOP.md'), + `# Loop Configuration + +## Patterns +- daily-triage + +## State Files +- STATE.md + +## Schedule +- Cadence: 1d +- Level: L1 +` + ); +} + +async function cleanupTestDir() { + await rm(testDir, { recursive: true, force: true }); +} + +describe('runSync', () => { + beforeEach(setupTestDir); + afterEach(cleanupTestDir); + + it('should return a valid DriftReport', async () => { + const report = await runSync({ targetDir: testDir }); + + assert.ok(typeof report.score === 'number'); + assert.ok(['healthy', 'warning', 'critical'].includes(report.level)); + assert.ok(Array.isArray(report.issues)); + assert.ok(Array.isArray(report.suggestions)); + assert.ok(report.timestamp); + }); + + it('should detect missing files', async () => { + // Remove AGENTS.md (it's not created by setup) + const report = await runSync({ targetDir: testDir }); + + // Should have at least one issue about missing AGENTS.md + const agentsIssue = report.issues.find(i => i.file === 'AGENTS.md'); + assert.ok(agentsIssue); + assert.ok(agentsIssue.message.includes('missing')); + }); + + it('should calculate score correctly', async () => { + const report = await runSync({ targetDir: testDir }); + + // With STATE.md and LOOP.md present, score should be reasonable + assert.ok(report.score >= 0); + assert.ok(report.score <= 100); + }); + + it('should provide suggestions', async () => { + const report = await runSync({ targetDir: testDir }); + + // Should have at least one suggestion + assert.ok(report.suggestions.length > 0); + }); +}); + +describe('formatReport', () => { + it('should format report as string', () => { + const report = { + score: 85, + level: 'healthy', + issues: [], + suggestions: ['Run loop-init'], + timestamp: new Date().toISOString(), + }; + + const formatted = formatReport(report); + + assert.ok(typeof formatted === 'string'); + assert.ok(formatted.includes('Loop Sync Report')); + assert.ok(formatted.includes('85/100')); + }); + + it('should show issues when present', () => { + const report = { + score: 60, + level: 'warning', + issues: [ + { + type: 'missing', + file: 'AGENTS.md', + message: 'AGENTS.md is missing', + severity: 'error', + }, + ], + suggestions: [], + timestamp: new Date().toISOString(), + }; + + const formatted = formatReport(report); + + assert.ok(formatted.includes('AGENTS.md')); + assert.ok(formatted.includes('missing')); + }); +}); \ No newline at end of file diff --git a/tools/loop-sync/tsconfig.json b/tools/loop-sync/tsconfig.json new file mode 100644 index 0000000..8e6e0b2 --- /dev/null +++ b/tools/loop-sync/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} \ No newline at end of file