* fix(db): remove unique constraint on folder to support multi-channel agents * ci: implement automated skill drift detection and self-healing PRs * fix: align registration logic with Gavriel's feedback and fix build/test issues from Daniel Mi * style: conform to prettier standards for CI validation * test: fix branch naming inconsistency in CI (master vs main) * fix(ci): robust module resolution by removing file extensions in scripts * refactor(ci): simplify skill validation by removing redundant combination tests * style: conform skills-engine to prettier, unify logging in index.ts and cleanup unused imports * refactor: extract multi-channel DB changes to separate branch Move channel column, folder suffix logic, and related migrations to feat/multi-channel-db-v2 for independent review. This PR now contains only CI/CD optimizations, Prettier formatting, and logging improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
153 lines
4.4 KiB
TypeScript
153 lines
4.4 KiB
TypeScript
import { execFileSync, execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { parse, stringify } from 'yaml';
|
|
|
|
import { BASE_DIR, CUSTOM_DIR } from './constants.js';
|
|
import {
|
|
computeFileHash,
|
|
readState,
|
|
recordCustomModification,
|
|
} from './state.js';
|
|
|
|
interface PendingCustomize {
|
|
description: string;
|
|
started_at: string;
|
|
file_hashes: Record<string, string>;
|
|
}
|
|
|
|
function getPendingPath(): string {
|
|
return path.join(process.cwd(), CUSTOM_DIR, 'pending.yaml');
|
|
}
|
|
|
|
export function isCustomizeActive(): boolean {
|
|
return fs.existsSync(getPendingPath());
|
|
}
|
|
|
|
export function startCustomize(description: string): void {
|
|
if (isCustomizeActive()) {
|
|
throw new Error(
|
|
'A customize session is already active. Commit or abort it first.',
|
|
);
|
|
}
|
|
|
|
const state = readState();
|
|
|
|
// Collect all file hashes from applied skills
|
|
const fileHashes: Record<string, string> = {};
|
|
for (const skill of state.applied_skills) {
|
|
for (const [relativePath, hash] of Object.entries(skill.file_hashes)) {
|
|
fileHashes[relativePath] = hash;
|
|
}
|
|
}
|
|
|
|
const pending: PendingCustomize = {
|
|
description,
|
|
started_at: new Date().toISOString(),
|
|
file_hashes: fileHashes,
|
|
};
|
|
|
|
const customDir = path.join(process.cwd(), CUSTOM_DIR);
|
|
fs.mkdirSync(customDir, { recursive: true });
|
|
fs.writeFileSync(getPendingPath(), stringify(pending), 'utf-8');
|
|
}
|
|
|
|
export function commitCustomize(): void {
|
|
const pendingPath = getPendingPath();
|
|
if (!fs.existsSync(pendingPath)) {
|
|
throw new Error('No active customize session. Run startCustomize() first.');
|
|
}
|
|
|
|
const pending = parse(
|
|
fs.readFileSync(pendingPath, 'utf-8'),
|
|
) as PendingCustomize;
|
|
const cwd = process.cwd();
|
|
|
|
// Find files that changed
|
|
const changedFiles: string[] = [];
|
|
for (const relativePath of Object.keys(pending.file_hashes)) {
|
|
const fullPath = path.join(cwd, relativePath);
|
|
if (!fs.existsSync(fullPath)) {
|
|
// File was deleted — counts as changed
|
|
changedFiles.push(relativePath);
|
|
continue;
|
|
}
|
|
const currentHash = computeFileHash(fullPath);
|
|
if (currentHash !== pending.file_hashes[relativePath]) {
|
|
changedFiles.push(relativePath);
|
|
}
|
|
}
|
|
|
|
if (changedFiles.length === 0) {
|
|
console.log(
|
|
'No files changed during customize session. Nothing to commit.',
|
|
);
|
|
fs.unlinkSync(pendingPath);
|
|
return;
|
|
}
|
|
|
|
// Generate unified diff for each changed file
|
|
const baseDir = path.join(cwd, BASE_DIR);
|
|
let combinedPatch = '';
|
|
|
|
for (const relativePath of changedFiles) {
|
|
const basePath = path.join(baseDir, relativePath);
|
|
const currentPath = path.join(cwd, relativePath);
|
|
|
|
// Use /dev/null if either side doesn't exist
|
|
const oldPath = fs.existsSync(basePath) ? basePath : '/dev/null';
|
|
const newPath = fs.existsSync(currentPath) ? currentPath : '/dev/null';
|
|
|
|
try {
|
|
const diff = execFileSync('diff', ['-ruN', oldPath, newPath], {
|
|
encoding: 'utf-8',
|
|
});
|
|
combinedPatch += diff;
|
|
} catch (err: unknown) {
|
|
const execErr = err as { status?: number; stdout?: string };
|
|
if (execErr.status === 1 && execErr.stdout) {
|
|
// diff exits 1 when files differ — that's expected
|
|
combinedPatch += execErr.stdout;
|
|
} else if (execErr.status === 2) {
|
|
throw new Error(
|
|
`diff error for ${relativePath}: diff exited with status 2 (check file permissions or encoding)`,
|
|
);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!combinedPatch.trim()) {
|
|
console.log('Diff was empty despite hash changes. Nothing to commit.');
|
|
fs.unlinkSync(pendingPath);
|
|
return;
|
|
}
|
|
|
|
// Determine sequence number
|
|
const state = readState();
|
|
const existingCount = state.custom_modifications?.length ?? 0;
|
|
const seqNum = String(existingCount + 1).padStart(3, '0');
|
|
|
|
// Sanitize description for filename
|
|
const sanitized = pending.description
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
const patchFilename = `${seqNum}-${sanitized}.patch`;
|
|
const patchRelPath = path.join(CUSTOM_DIR, patchFilename);
|
|
const patchFullPath = path.join(cwd, patchRelPath);
|
|
|
|
fs.writeFileSync(patchFullPath, combinedPatch, 'utf-8');
|
|
recordCustomModification(pending.description, changedFiles, patchRelPath);
|
|
fs.unlinkSync(pendingPath);
|
|
}
|
|
|
|
export function abortCustomize(): void {
|
|
const pendingPath = getPendingPath();
|
|
if (fs.existsSync(pendingPath)) {
|
|
fs.unlinkSync(pendingPath);
|
|
}
|
|
}
|