Files
nanoclaw/skills-engine/customize.ts
Gabi Simons 11c201088b refactor: CI optimization, logging improvements, and codebase formatting (#456)
* 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>
2026-02-25 23:13:36 +02:00

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);
}
}