Skills engine v0.1 + multi-channel infrastructure (#307)
* refactor: multi-channel infrastructure with explicit channel/is_group tracking - Add channels[] array and findChannel() routing in index.ts, replacing hardcoded whatsapp.* calls with channel-agnostic callbacks - Add channel TEXT and is_group INTEGER columns to chats table with COALESCE upsert to protect existing values from null overwrites - is_group defaults to 0 (safe: unknown chats excluded from groups) - WhatsApp passes explicit channel='whatsapp' and isGroup to onChatMetadata - getAvailableGroups filters on is_group instead of JID pattern matching - findChannel logs warnings instead of silently dropping unroutable JIDs - Migration backfills channel/is_group from JID patterns for existing DBs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: skills engine v0.1 — deterministic skill packages with rerere resolution Three-way merge engine for applying skill packages on top of a core codebase. Skills declare which files they add/modify, and the engine uses git merge-file for conflict detection with git rerere for automatic resolution of previously-seen conflicts. Key components: - apply: three-way merge with backup/rollback safety net - replay: clean-slate replay for uninstall and rebase - update: core version updates with deletion detection - rebase: bake applied skills into base (one-way) - manifest: validation with path traversal protection - resolution-cache: pre-computed rerere resolutions - structured: npm deps, env vars, docker-compose merging - CI: per-skill test matrix with conflict detection 151 unit tests covering merge, rerere, backup, replay, uninstall, update, rebase, structured ops, and edge cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Discord and Telegram skill packages Skill packages for adding Discord and Telegram channels to NanoClaw. Each package includes: - Channel implementation (add/src/channels/) - Three-way merge targets for index.ts, config.ts, routing.test.ts - Intent docs explaining merge invariants - Standalone integration tests - manifest.yaml with dependency/conflict declarations Applied via: npx tsx scripts/apply-skill.ts .claude/skills/add-discord These are inert until applied — no runtime impact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * remove unused docs (skills-system-status, implementation-guide) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
309
skills-engine/replay.ts
Normal file
309
skills-engine/replay.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { BASE_DIR, NANOCLAW_DIR } from './constants.js';
|
||||
import { copyDir } from './fs-utils.js';
|
||||
import { readManifest } from './manifest.js';
|
||||
import {
|
||||
cleanupMergeState,
|
||||
isGitRepo,
|
||||
mergeFile,
|
||||
runRerere,
|
||||
setupRerereAdapter,
|
||||
} from './merge.js';
|
||||
import { loadPathRemap, resolvePathRemap } from './path-remap.js';
|
||||
import { loadResolutions } from './resolution-cache.js';
|
||||
import {
|
||||
mergeDockerComposeServices,
|
||||
mergeEnvAdditions,
|
||||
mergeNpmDependencies,
|
||||
runNpmInstall,
|
||||
} from './structured.js';
|
||||
|
||||
export interface ReplayOptions {
|
||||
skills: string[];
|
||||
skillDirs: Record<string, string>;
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export interface ReplayResult {
|
||||
success: boolean;
|
||||
perSkill: Record<string, { success: boolean; error?: string }>;
|
||||
mergeConflicts?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan .claude/skills/ for a directory whose manifest.yaml has skill: <skillName>.
|
||||
*/
|
||||
export function findSkillDir(
|
||||
skillName: string,
|
||||
projectRoot?: string,
|
||||
): string | null {
|
||||
const root = projectRoot ?? process.cwd();
|
||||
const skillsRoot = path.join(root, '.claude', 'skills');
|
||||
if (!fs.existsSync(skillsRoot)) return null;
|
||||
|
||||
for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = path.join(skillsRoot, entry.name);
|
||||
const manifestPath = path.join(dir, 'manifest.yaml');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
try {
|
||||
const manifest = readManifest(dir);
|
||||
if (manifest.skill === skillName) return dir;
|
||||
} catch {
|
||||
// Skip invalid manifests
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a list of skills from clean base state.
|
||||
* Used by uninstall (replay-without) and rebase.
|
||||
*/
|
||||
export async function replaySkills(
|
||||
options: ReplayOptions,
|
||||
): Promise<ReplayResult> {
|
||||
const projectRoot = options.projectRoot ?? process.cwd();
|
||||
const baseDir = path.join(projectRoot, BASE_DIR);
|
||||
const pathRemap = loadPathRemap();
|
||||
|
||||
const perSkill: Record<string, { success: boolean; error?: string }> = {};
|
||||
const allMergeConflicts: string[] = [];
|
||||
|
||||
// 1. Collect all files touched by any skill in the list
|
||||
const allTouchedFiles = new Set<string>();
|
||||
for (const skillName of options.skills) {
|
||||
const skillDir = options.skillDirs[skillName];
|
||||
if (!skillDir) {
|
||||
perSkill[skillName] = {
|
||||
success: false,
|
||||
error: `Skill directory not found for: ${skillName}`,
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
perSkill,
|
||||
error: `Missing skill directory for: ${skillName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const manifest = readManifest(skillDir);
|
||||
for (const f of manifest.adds) allTouchedFiles.add(f);
|
||||
for (const f of manifest.modifies) allTouchedFiles.add(f);
|
||||
}
|
||||
|
||||
// 2. Reset touched files to clean base
|
||||
for (const relPath of allTouchedFiles) {
|
||||
const resolvedPath = resolvePathRemap(relPath, pathRemap);
|
||||
const currentPath = path.join(projectRoot, resolvedPath);
|
||||
const basePath = path.join(baseDir, resolvedPath);
|
||||
|
||||
if (fs.existsSync(basePath)) {
|
||||
// Restore from base
|
||||
fs.mkdirSync(path.dirname(currentPath), { recursive: true });
|
||||
fs.copyFileSync(basePath, currentPath);
|
||||
} else if (fs.existsSync(currentPath)) {
|
||||
// Add-only file not in base — remove it
|
||||
fs.unlinkSync(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load pre-computed resolutions into git's rr-cache before replaying
|
||||
// Pass the last skill's dir — it's the one applied on top, producing conflicts
|
||||
const lastSkillDir = options.skills.length > 0
|
||||
? options.skillDirs[options.skills[options.skills.length - 1]]
|
||||
: undefined;
|
||||
loadResolutions(options.skills, projectRoot, lastSkillDir);
|
||||
|
||||
// Replay each skill in order
|
||||
// Collect structured ops for batch application
|
||||
const allNpmDeps: Record<string, string> = {};
|
||||
const allEnvAdditions: string[] = [];
|
||||
const allDockerServices: Record<string, unknown> = {};
|
||||
let hasNpmDeps = false;
|
||||
|
||||
for (const skillName of options.skills) {
|
||||
const skillDir = options.skillDirs[skillName];
|
||||
try {
|
||||
const manifest = readManifest(skillDir);
|
||||
|
||||
// Execute file_ops
|
||||
if (manifest.file_ops && manifest.file_ops.length > 0) {
|
||||
const { executeFileOps } = await import('./file-ops.js');
|
||||
const fileOpsResult = executeFileOps(manifest.file_ops, projectRoot);
|
||||
if (!fileOpsResult.success) {
|
||||
perSkill[skillName] = {
|
||||
success: false,
|
||||
error: `File operations failed: ${fileOpsResult.errors.join('; ')}`,
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
perSkill,
|
||||
error: `File ops failed for ${skillName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Copy add/ files
|
||||
const addDir = path.join(skillDir, 'add');
|
||||
if (fs.existsSync(addDir)) {
|
||||
for (const relPath of manifest.adds) {
|
||||
const resolvedDest = resolvePathRemap(relPath, pathRemap);
|
||||
const destPath = path.join(projectRoot, resolvedDest);
|
||||
const srcPath = path.join(addDir, relPath);
|
||||
if (fs.existsSync(srcPath)) {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Three-way merge modify/ files
|
||||
const skillConflicts: string[] = [];
|
||||
|
||||
for (const relPath of manifest.modifies) {
|
||||
const resolvedPath = resolvePathRemap(relPath, pathRemap);
|
||||
const currentPath = path.join(projectRoot, resolvedPath);
|
||||
const basePath = path.join(baseDir, resolvedPath);
|
||||
const skillPath = path.join(skillDir, 'modify', relPath);
|
||||
|
||||
if (!fs.existsSync(skillPath)) {
|
||||
skillConflicts.push(relPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
fs.mkdirSync(path.dirname(currentPath), { recursive: true });
|
||||
fs.copyFileSync(skillPath, currentPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
fs.mkdirSync(path.dirname(basePath), { recursive: true });
|
||||
fs.copyFileSync(currentPath, basePath);
|
||||
}
|
||||
|
||||
const oursContent = fs.readFileSync(currentPath, 'utf-8');
|
||||
const tmpCurrent = path.join(
|
||||
os.tmpdir(),
|
||||
`nanoclaw-replay-${crypto.randomUUID()}-${path.basename(relPath)}`,
|
||||
);
|
||||
fs.copyFileSync(currentPath, tmpCurrent);
|
||||
|
||||
const result = mergeFile(tmpCurrent, basePath, skillPath);
|
||||
|
||||
if (result.clean) {
|
||||
fs.copyFileSync(tmpCurrent, currentPath);
|
||||
fs.unlinkSync(tmpCurrent);
|
||||
} else {
|
||||
fs.copyFileSync(tmpCurrent, currentPath);
|
||||
fs.unlinkSync(tmpCurrent);
|
||||
|
||||
if (isGitRepo()) {
|
||||
const baseContent = fs.readFileSync(basePath, 'utf-8');
|
||||
const theirsContent = fs.readFileSync(skillPath, 'utf-8');
|
||||
|
||||
setupRerereAdapter(
|
||||
resolvedPath,
|
||||
baseContent,
|
||||
oursContent,
|
||||
theirsContent,
|
||||
);
|
||||
const autoResolved = runRerere(currentPath);
|
||||
|
||||
if (autoResolved) {
|
||||
execFileSync('git', ['add', resolvedPath], { stdio: 'pipe' });
|
||||
execSync('git rerere', { stdio: 'pipe' });
|
||||
cleanupMergeState(resolvedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanupMergeState(resolvedPath);
|
||||
}
|
||||
|
||||
skillConflicts.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (skillConflicts.length > 0) {
|
||||
allMergeConflicts.push(...skillConflicts);
|
||||
perSkill[skillName] = {
|
||||
success: false,
|
||||
error: `Merge conflicts: ${skillConflicts.join(', ')}`,
|
||||
};
|
||||
// Stop on first conflict — later skills would merge against conflict markers
|
||||
break;
|
||||
} else {
|
||||
perSkill[skillName] = { success: true };
|
||||
}
|
||||
|
||||
// Collect structured ops
|
||||
if (manifest.structured?.npm_dependencies) {
|
||||
Object.assign(allNpmDeps, manifest.structured.npm_dependencies);
|
||||
hasNpmDeps = true;
|
||||
}
|
||||
if (manifest.structured?.env_additions) {
|
||||
allEnvAdditions.push(...manifest.structured.env_additions);
|
||||
}
|
||||
if (manifest.structured?.docker_compose_services) {
|
||||
Object.assign(
|
||||
allDockerServices,
|
||||
manifest.structured.docker_compose_services,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
perSkill[skillName] = {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
perSkill,
|
||||
error: `Replay failed for ${skillName}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (allMergeConflicts.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
perSkill,
|
||||
mergeConflicts: allMergeConflicts,
|
||||
error: `Unresolved merge conflicts: ${allMergeConflicts.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Apply aggregated structured operations (only if no conflicts)
|
||||
if (hasNpmDeps) {
|
||||
const pkgPath = path.join(projectRoot, 'package.json');
|
||||
mergeNpmDependencies(pkgPath, allNpmDeps);
|
||||
}
|
||||
|
||||
if (allEnvAdditions.length > 0) {
|
||||
const envPath = path.join(projectRoot, '.env.example');
|
||||
mergeEnvAdditions(envPath, allEnvAdditions);
|
||||
}
|
||||
|
||||
if (Object.keys(allDockerServices).length > 0) {
|
||||
const composePath = path.join(projectRoot, 'docker-compose.yml');
|
||||
mergeDockerComposeServices(composePath, allDockerServices);
|
||||
}
|
||||
|
||||
// 5. Run npm install if any deps
|
||||
if (hasNpmDeps) {
|
||||
try {
|
||||
runNpmInstall();
|
||||
} catch {
|
||||
// npm install failure is non-fatal for replay
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, perSkill };
|
||||
}
|
||||
Reference in New Issue
Block a user