wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation

This commit is contained in:
2026-03-29 22:08:40 +09:00
parent aca7bf592a
commit 2507de45d3
4289 changed files with 732689 additions and 28672 deletions

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env node
const {
getInstallComponent,
listInstallComponents,
listInstallProfiles,
} = require('./lib/install-manifests');
const FAMILY_ALIASES = Object.freeze({
baseline: 'baseline',
baselines: 'baseline',
language: 'language',
languages: 'language',
lang: 'language',
framework: 'framework',
frameworks: 'framework',
capability: 'capability',
capabilities: 'capability',
agent: 'agent',
agents: 'agent',
skill: 'skill',
skills: 'skill',
});
function showHelp(exitCode = 0) {
console.log(`
Discover ECC install components and profiles
Usage:
node scripts/catalog.js profiles [--json]
node scripts/catalog.js components [--family <family>] [--target <target>] [--json]
node scripts/catalog.js show <component-id> [--json]
Examples:
node scripts/catalog.js profiles
node scripts/catalog.js components --family language
node scripts/catalog.js show framework:nextjs
`);
process.exit(exitCode);
}
function normalizeFamily(value) {
if (!value) {
return null;
}
const normalized = String(value).trim().toLowerCase();
return FAMILY_ALIASES[normalized] || normalized;
}
function parseArgs(argv) {
const args = argv.slice(2);
const parsed = {
command: null,
componentId: null,
family: null,
target: null,
json: false,
help: false,
};
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
parsed.help = true;
return parsed;
}
parsed.command = args[0];
for (let index = 1; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else if (arg === '--json') {
parsed.json = true;
} else if (arg === '--family') {
if (!args[index + 1]) {
throw new Error('Missing value for --family');
}
parsed.family = normalizeFamily(args[index + 1]);
index += 1;
} else if (arg === '--target') {
if (!args[index + 1]) {
throw new Error('Missing value for --target');
}
parsed.target = args[index + 1];
index += 1;
} else if (parsed.command === 'show' && !parsed.componentId) {
parsed.componentId = arg;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return parsed;
}
function printProfiles(profiles) {
console.log('Install profiles:\n');
for (const profile of profiles) {
console.log(`- ${profile.id} (${profile.moduleCount} modules)`);
console.log(` ${profile.description}`);
}
}
function printComponents(components) {
console.log('Install components:\n');
for (const component of components) {
console.log(`- ${component.id} [${component.family}]`);
console.log(` targets=${component.targets.join(', ')} modules=${component.moduleIds.join(', ')}`);
console.log(` ${component.description}`);
}
}
function printComponent(component) {
console.log(`Install component: ${component.id}\n`);
console.log(`Family: ${component.family}`);
console.log(`Targets: ${component.targets.join(', ')}`);
console.log(`Modules: ${component.moduleIds.join(', ')}`);
console.log(`Description: ${component.description}`);
if (component.modules.length > 0) {
console.log('\nResolved modules:');
for (const module of component.modules) {
console.log(`- ${module.id} [${module.kind}]`);
console.log(
` targets=${module.targets.join(', ')} default=${module.defaultInstall} cost=${module.cost} stability=${module.stability}`
);
console.log(` ${module.description}`);
}
}
}
function main() {
try {
const options = parseArgs(process.argv);
if (options.help) {
showHelp(0);
}
if (options.command === 'profiles') {
const profiles = listInstallProfiles();
if (options.json) {
console.log(JSON.stringify({ profiles }, null, 2));
} else {
printProfiles(profiles);
}
return;
}
if (options.command === 'components') {
const components = listInstallComponents({
family: options.family,
target: options.target,
});
if (options.json) {
console.log(JSON.stringify({ components }, null, 2));
} else {
printComponents(components);
}
return;
}
if (options.command === 'show') {
if (!options.componentId) {
throw new Error('Catalog show requires an install component ID');
}
const component = getInstallComponent(options.componentId);
if (options.json) {
console.log(JSON.stringify(component, null, 2));
} else {
printComponent(component);
}
return;
}
throw new Error(`Unknown catalog command: ${options.command}`);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* Verify repo catalog counts against README.md and AGENTS.md.
*
* Usage:
* node scripts/ci/catalog.js
* node scripts/ci/catalog.js --json
* node scripts/ci/catalog.js --md
* node scripts/ci/catalog.js --text
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '../..');
const README_PATH = path.join(ROOT, 'README.md');
const AGENTS_PATH = path.join(ROOT, 'AGENTS.md');
const OUTPUT_MODE = process.argv.includes('--md')
? 'md'
: process.argv.includes('--text')
? 'text'
: 'json';
function normalizePathSegments(relativePath) {
return relativePath.split(path.sep).join('/');
}
function listMatchingFiles(relativeDir, matcher) {
const directory = path.join(ROOT, relativeDir);
if (!fs.existsSync(directory)) {
return [];
}
return fs.readdirSync(directory, { withFileTypes: true })
.filter(entry => matcher(entry))
.map(entry => normalizePathSegments(path.join(relativeDir, entry.name)))
.sort();
}
function buildCatalog() {
const agents = listMatchingFiles('agents', entry => entry.isFile() && entry.name.endsWith('.md'));
const commands = listMatchingFiles('commands', entry => entry.isFile() && entry.name.endsWith('.md'));
const skills = listMatchingFiles('skills', entry => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md')))
.map(skillDir => `${skillDir}/SKILL.md`);
return {
agents: { count: agents.length, files: agents, glob: 'agents/*.md' },
commands: { count: commands.length, files: commands, glob: 'commands/*.md' },
skills: { count: skills.length, files: skills, glob: 'skills/*/SKILL.md' }
};
}
function readFileOrThrow(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (error) {
throw new Error(`Failed to read ${path.basename(filePath)}: ${error.message}`);
}
}
function parseReadmeExpectations(readmeContent) {
const expectations = [];
const quickStartMatch = readmeContent.match(/access to\s+(\d+)\s+agents,\s+(\d+)\s+skills,\s+and\s+(\d+)\s+commands/i);
if (!quickStartMatch) {
throw new Error('README.md is missing the quick-start catalog summary');
}
expectations.push(
{ category: 'agents', mode: 'exact', expected: Number(quickStartMatch[1]), source: 'README.md quick-start summary' },
{ category: 'skills', mode: 'exact', expected: Number(quickStartMatch[2]), source: 'README.md quick-start summary' },
{ category: 'commands', mode: 'exact', expected: Number(quickStartMatch[3]), source: 'README.md quick-start summary' }
);
const tablePatterns = [
{ category: 'agents', regex: /\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*✅\s*(\d+)\s+agents\s*\|/i, source: 'README.md comparison table' },
{ category: 'commands', regex: /\|\s*(?:\*\*)?Commands(?:\*\*)?\s*\|\s*✅\s*(\d+)\s+commands\s*\|/i, source: 'README.md comparison table' },
{ category: 'skills', regex: /\|\s*(?:\*\*)?Skills(?:\*\*)?\s*\|\s*✅\s*(\d+)\s+skills\s*\|/i, source: 'README.md comparison table' }
];
for (const pattern of tablePatterns) {
const match = readmeContent.match(pattern.regex);
if (!match) {
throw new Error(`${pattern.source} is missing the ${pattern.category} row`);
}
expectations.push({
category: pattern.category,
mode: 'exact',
expected: Number(match[1]),
source: `${pattern.source} (${pattern.category})`
});
}
return expectations;
}
function parseAgentsDocExpectations(agentsContent) {
const summaryMatch = agentsContent.match(/providing\s+(\d+)\s+specialized agents,\s+(\d+)(\+)?\s+skills,\s+(\d+)\s+commands/i);
if (!summaryMatch) {
throw new Error('AGENTS.md is missing the catalog summary line');
}
const expectations = [
{ category: 'agents', mode: 'exact', expected: Number(summaryMatch[1]), source: 'AGENTS.md summary' },
{
category: 'skills',
mode: summaryMatch[3] ? 'minimum' : 'exact',
expected: Number(summaryMatch[2]),
source: 'AGENTS.md summary'
},
{ category: 'commands', mode: 'exact', expected: Number(summaryMatch[4]), source: 'AGENTS.md summary' }
];
const structurePatterns = [
{
category: 'agents',
mode: 'exact',
regex: /^\s*agents\/\s*[—–-]\s*(\d+)\s+specialized subagents\s*$/im,
source: 'AGENTS.md project structure'
},
{
category: 'skills',
mode: 'minimum',
regex: /^\s*skills\/\s*[—–-]\s*(\d+)(\+)?\s+workflow skills and domain knowledge\s*$/im,
source: 'AGENTS.md project structure'
},
{
category: 'commands',
mode: 'exact',
regex: /^\s*commands\/\s*[—–-]\s*(\d+)\s+slash commands\s*$/im,
source: 'AGENTS.md project structure'
}
];
for (const pattern of structurePatterns) {
const match = agentsContent.match(pattern.regex);
if (!match) {
throw new Error(`${pattern.source} is missing the ${pattern.category} entry`);
}
expectations.push({
category: pattern.category,
mode: pattern.mode === 'minimum' && match[2] ? 'minimum' : pattern.mode,
expected: Number(match[1]),
source: `${pattern.source} (${pattern.category})`
});
}
return expectations;
}
function evaluateExpectations(catalog, expectations) {
return expectations.map(expectation => {
const actual = catalog[expectation.category].count;
const ok = expectation.mode === 'minimum'
? actual >= expectation.expected
: actual === expectation.expected;
return {
...expectation,
actual,
ok
};
});
}
function formatExpectation(expectation) {
const comparator = expectation.mode === 'minimum' ? '>=' : '=';
return `${expectation.source}: ${expectation.category} documented ${comparator} ${expectation.expected}, actual ${expectation.actual}`;
}
function renderText(result) {
console.log('Catalog counts:');
console.log(`- agents: ${result.catalog.agents.count}`);
console.log(`- commands: ${result.catalog.commands.count}`);
console.log(`- skills: ${result.catalog.skills.count}`);
console.log('');
const mismatches = result.checks.filter(check => !check.ok);
if (mismatches.length === 0) {
console.log('Documentation counts match the repository catalog.');
return;
}
console.error('Documentation count mismatches found:');
for (const mismatch of mismatches) {
console.error(`- ${formatExpectation(mismatch)}`);
}
}
function renderMarkdown(result) {
const mismatches = result.checks.filter(check => !check.ok);
console.log('# ECC Catalog Verification\n');
console.log('| Category | Count | Pattern |');
console.log('| --- | ---: | --- |');
console.log(`| Agents | ${result.catalog.agents.count} | \`${result.catalog.agents.glob}\` |`);
console.log(`| Commands | ${result.catalog.commands.count} | \`${result.catalog.commands.glob}\` |`);
console.log(`| Skills | ${result.catalog.skills.count} | \`${result.catalog.skills.glob}\` |`);
console.log('');
if (mismatches.length === 0) {
console.log('Documentation counts match the repository catalog.');
return;
}
console.log('## Mismatches\n');
for (const mismatch of mismatches) {
console.log(`- ${formatExpectation(mismatch)}`);
}
}
function main() {
const catalog = buildCatalog();
const readmeContent = readFileOrThrow(README_PATH);
const agentsContent = readFileOrThrow(AGENTS_PATH);
const expectations = [
...parseReadmeExpectations(readmeContent),
...parseAgentsDocExpectations(agentsContent)
];
const checks = evaluateExpectations(catalog, expectations);
const result = { catalog, checks };
if (OUTPUT_MODE === 'json') {
console.log(JSON.stringify(result, null, 2));
} else if (OUTPUT_MODE === 'md') {
renderMarkdown(result);
} else {
renderText(result);
}
if (checks.some(check => !check.ok)) {
process.exit(1);
}
}
try {
main();
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* Validate agent markdown files have required frontmatter
*/
const fs = require('fs');
const path = require('path');
const AGENTS_DIR = path.join(__dirname, '../../agents');
const REQUIRED_FIELDS = ['model', 'tools'];
const VALID_MODELS = ['haiku', 'sonnet', 'opus'];
function extractFrontmatter(content) {
// Strip BOM if present (UTF-8 BOM: \uFEFF)
const cleanContent = content.replace(/^\uFEFF/, '');
// Support both LF and CRLF line endings
const match = cleanContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;
const frontmatter = {};
const lines = match[1].split(/\r?\n/);
for (const line of lines) {
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim();
frontmatter[key] = value;
}
}
return frontmatter;
}
function validateAgents() {
if (!fs.existsSync(AGENTS_DIR)) {
console.log('No agents directory found, skipping validation');
process.exit(0);
}
const files = fs.readdirSync(AGENTS_DIR).filter(f => f.endsWith('.md'));
let hasErrors = false;
for (const file of files) {
const filePath = path.join(AGENTS_DIR, file);
let content;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
console.error(`ERROR: ${file} - ${err.message}`);
hasErrors = true;
continue;
}
const frontmatter = extractFrontmatter(content);
if (!frontmatter) {
console.error(`ERROR: ${file} - Missing frontmatter`);
hasErrors = true;
continue;
}
for (const field of REQUIRED_FIELDS) {
if (!frontmatter[field] || (typeof frontmatter[field] === 'string' && !frontmatter[field].trim())) {
console.error(`ERROR: ${file} - Missing required field: ${field}`);
hasErrors = true;
}
}
// Validate model is a known value
if (frontmatter.model && !VALID_MODELS.includes(frontmatter.model)) {
console.error(`ERROR: ${file} - Invalid model '${frontmatter.model}'. Must be one of: ${VALID_MODELS.join(', ')}`);
hasErrors = true;
}
}
if (hasErrors) {
process.exit(1);
}
console.log(`Validated ${files.length} agent files`);
}
validateAgents();

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* Validate command markdown files are non-empty, readable,
* and have valid cross-references to other commands, agents, and skills.
*/
const fs = require('fs');
const path = require('path');
const ROOT_DIR = path.join(__dirname, '../..');
const COMMANDS_DIR = path.join(ROOT_DIR, 'commands');
const AGENTS_DIR = path.join(ROOT_DIR, 'agents');
const SKILLS_DIR = path.join(ROOT_DIR, 'skills');
function validateCommands() {
if (!fs.existsSync(COMMANDS_DIR)) {
console.log('No commands directory found, skipping validation');
process.exit(0);
}
const files = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
let hasErrors = false;
let warnCount = 0;
// Build set of valid command names (without .md extension)
const validCommands = new Set(files.map(f => f.replace(/\.md$/, '')));
// Build set of valid agent names (without .md extension)
const validAgents = new Set();
if (fs.existsSync(AGENTS_DIR)) {
for (const f of fs.readdirSync(AGENTS_DIR)) {
if (f.endsWith('.md')) {
validAgents.add(f.replace(/\.md$/, ''));
}
}
}
// Build set of valid skill directory names
const validSkills = new Set();
if (fs.existsSync(SKILLS_DIR)) {
for (const f of fs.readdirSync(SKILLS_DIR)) {
const skillPath = path.join(SKILLS_DIR, f);
try {
if (fs.statSync(skillPath).isDirectory()) {
validSkills.add(f);
}
} catch {
// skip unreadable entries
}
}
}
for (const file of files) {
const filePath = path.join(COMMANDS_DIR, file);
let content;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
console.error(`ERROR: ${file} - ${err.message}`);
hasErrors = true;
continue;
}
// Validate the file is non-empty readable markdown
if (content.trim().length === 0) {
console.error(`ERROR: ${file} - Empty command file`);
hasErrors = true;
continue;
}
// Strip fenced code blocks before checking cross-references.
// Examples/templates inside ``` blocks are not real references.
const contentNoCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
// Check cross-references to other commands (e.g., `/build-fix`)
// Skip lines that describe hypothetical output (e.g., "→ Creates: `/new-table`")
// Process line-by-line so ALL command refs per line are captured
// (previous anchored regex /^.*`\/...`.*$/gm only matched the last ref per line)
for (const line of contentNoCodeBlocks.split('\n')) {
if (/creates:|would create:/i.test(line)) continue;
const lineRefs = line.matchAll(/`\/([a-z][-a-z0-9]*)`/g);
for (const match of lineRefs) {
const refName = match[1];
if (!validCommands.has(refName)) {
console.error(`ERROR: ${file} - references non-existent command /${refName}`);
hasErrors = true;
}
}
}
// Check agent references (e.g., "agents/planner.md" or "`planner` agent")
const agentPathRefs = contentNoCodeBlocks.matchAll(/agents\/([a-z][-a-z0-9]*)\.md/g);
for (const match of agentPathRefs) {
const refName = match[1];
if (!validAgents.has(refName)) {
console.error(`ERROR: ${file} - references non-existent agent agents/${refName}.md`);
hasErrors = true;
}
}
// Check skill directory references (e.g., "skills/tdd-workflow/")
// learned and imported are reserved roots (~/.claude/skills/); no local dir expected
const reservedSkillRoots = new Set(['learned', 'imported']);
const skillRefs = contentNoCodeBlocks.matchAll(/skills\/([a-z][-a-z0-9]*)\//g);
for (const match of skillRefs) {
const refName = match[1];
if (reservedSkillRoots.has(refName) || validSkills.has(refName)) continue;
console.warn(`WARN: ${file} - references skill directory skills/${refName}/ (not found locally)`);
warnCount++;
}
// Check agent name references in workflow diagrams (e.g., "planner -> tdd-guide")
const workflowLines = contentNoCodeBlocks.matchAll(/^([a-z][-a-z0-9]*(?:\s*->\s*[a-z][-a-z0-9]*)+)$/gm);
for (const match of workflowLines) {
const agents = match[1].split(/\s*->\s*/);
for (const agent of agents) {
if (!validAgents.has(agent)) {
console.error(`ERROR: ${file} - workflow references non-existent agent "${agent}"`);
hasErrors = true;
}
}
}
}
if (hasErrors) {
process.exit(1);
}
let msg = `Validated ${files.length} command files`;
if (warnCount > 0) {
msg += ` (${warnCount} warnings)`;
}
console.log(msg);
}
validateCommands();

View File

@@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* Validate hooks.json schema and hook entry rules.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const Ajv = require('ajv');
const HOOKS_FILE = path.join(__dirname, '../../hooks/hooks.json');
const HOOKS_SCHEMA_PATH = path.join(__dirname, '../../schemas/hooks.schema.json');
const VALID_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'PostToolUseFailure',
'Notification',
'SubagentStart',
'Stop',
'SubagentStop',
'PreCompact',
'InstructionsLoaded',
'TeammateIdle',
'TaskCompleted',
'ConfigChange',
'WorktreeCreate',
'WorktreeRemove',
'SessionEnd',
];
const VALID_HOOK_TYPES = ['command', 'http', 'prompt', 'agent'];
const EVENTS_WITHOUT_MATCHER = new Set(['UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop']);
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isNonEmptyStringArray(value) {
return Array.isArray(value) && value.length > 0 && value.every(item => isNonEmptyString(item));
}
/**
* Validate a single hook entry has required fields and valid inline JS
* @param {object} hook - Hook object with type and command fields
* @param {string} label - Label for error messages (e.g., "PreToolUse[0].hooks[1]")
* @returns {boolean} true if errors were found
*/
function validateHookEntry(hook, label) {
let hasErrors = false;
if (!hook.type || typeof hook.type !== 'string') {
console.error(`ERROR: ${label} missing or invalid 'type' field`);
hasErrors = true;
} else if (!VALID_HOOK_TYPES.includes(hook.type)) {
console.error(`ERROR: ${label} has unsupported hook type '${hook.type}'`);
hasErrors = true;
}
if ('timeout' in hook && (typeof hook.timeout !== 'number' || hook.timeout < 0)) {
console.error(`ERROR: ${label} 'timeout' must be a non-negative number`);
hasErrors = true;
}
if (hook.type === 'command') {
if ('async' in hook && typeof hook.async !== 'boolean') {
console.error(`ERROR: ${label} 'async' must be a boolean`);
hasErrors = true;
}
if (!isNonEmptyString(hook.command) && !isNonEmptyStringArray(hook.command)) {
console.error(`ERROR: ${label} missing or invalid 'command' field`);
hasErrors = true;
} else if (typeof hook.command === 'string') {
const nodeEMatch = hook.command.match(/^node -e "(.*)"$/s);
if (nodeEMatch) {
try {
new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t'));
} catch (syntaxErr) {
console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`);
hasErrors = true;
}
}
}
return hasErrors;
}
if ('async' in hook) {
console.error(`ERROR: ${label} 'async' is only supported for command hooks`);
hasErrors = true;
}
if (hook.type === 'http') {
if (!isNonEmptyString(hook.url)) {
console.error(`ERROR: ${label} missing or invalid 'url' field`);
hasErrors = true;
}
if ('headers' in hook && (typeof hook.headers !== 'object' || hook.headers === null || Array.isArray(hook.headers) || !Object.values(hook.headers).every(value => typeof value === 'string'))) {
console.error(`ERROR: ${label} 'headers' must be an object with string values`);
hasErrors = true;
}
if ('allowedEnvVars' in hook && (!Array.isArray(hook.allowedEnvVars) || !hook.allowedEnvVars.every(value => isNonEmptyString(value)))) {
console.error(`ERROR: ${label} 'allowedEnvVars' must be an array of strings`);
hasErrors = true;
}
return hasErrors;
}
if (!isNonEmptyString(hook.prompt)) {
console.error(`ERROR: ${label} missing or invalid 'prompt' field`);
hasErrors = true;
}
if ('model' in hook && !isNonEmptyString(hook.model)) {
console.error(`ERROR: ${label} 'model' must be a non-empty string`);
hasErrors = true;
}
return hasErrors;
}
function validateHooks() {
if (!fs.existsSync(HOOKS_FILE)) {
console.log('No hooks.json found, skipping validation');
process.exit(0);
}
let data;
try {
data = JSON.parse(fs.readFileSync(HOOKS_FILE, 'utf-8'));
} catch (e) {
console.error(`ERROR: Invalid JSON in hooks.json: ${e.message}`);
process.exit(1);
}
// Validate against JSON schema
if (fs.existsSync(HOOKS_SCHEMA_PATH)) {
const schema = JSON.parse(fs.readFileSync(HOOKS_SCHEMA_PATH, 'utf-8'));
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid) {
for (const err of validate.errors) {
console.error(`ERROR: hooks.json schema: ${err.instancePath || '/'} ${err.message}`);
}
process.exit(1);
}
}
// Support both object format { hooks: {...} } and array format
const hooks = data.hooks || data;
let hasErrors = false;
let totalMatchers = 0;
if (typeof hooks === 'object' && !Array.isArray(hooks)) {
// Object format: { EventType: [matchers] }
for (const [eventType, matchers] of Object.entries(hooks)) {
if (!VALID_EVENTS.includes(eventType)) {
console.error(`ERROR: Invalid event type: ${eventType}`);
hasErrors = true;
continue;
}
if (!Array.isArray(matchers)) {
console.error(`ERROR: ${eventType} must be an array`);
hasErrors = true;
continue;
}
for (let i = 0; i < matchers.length; i++) {
const matcher = matchers[i];
if (typeof matcher !== 'object' || matcher === null) {
console.error(`ERROR: ${eventType}[${i}] is not an object`);
hasErrors = true;
continue;
}
if (!('matcher' in matcher) && !EVENTS_WITHOUT_MATCHER.has(eventType)) {
console.error(`ERROR: ${eventType}[${i}] missing 'matcher' field`);
hasErrors = true;
} else if ('matcher' in matcher && typeof matcher.matcher !== 'string' && (typeof matcher.matcher !== 'object' || matcher.matcher === null)) {
console.error(`ERROR: ${eventType}[${i}] has invalid 'matcher' field`);
hasErrors = true;
}
if (!matcher.hooks || !Array.isArray(matcher.hooks)) {
console.error(`ERROR: ${eventType}[${i}] missing 'hooks' array`);
hasErrors = true;
} else {
// Validate each hook entry
for (let j = 0; j < matcher.hooks.length; j++) {
if (validateHookEntry(matcher.hooks[j], `${eventType}[${i}].hooks[${j}]`)) {
hasErrors = true;
}
}
}
totalMatchers++;
}
}
} else if (Array.isArray(hooks)) {
// Array format (legacy)
for (let i = 0; i < hooks.length; i++) {
const hook = hooks[i];
if (!('matcher' in hook)) {
console.error(`ERROR: Hook ${i} missing 'matcher' field`);
hasErrors = true;
} else if (typeof hook.matcher !== 'string' && (typeof hook.matcher !== 'object' || hook.matcher === null)) {
console.error(`ERROR: Hook ${i} has invalid 'matcher' field`);
hasErrors = true;
}
if (!hook.hooks || !Array.isArray(hook.hooks)) {
console.error(`ERROR: Hook ${i} missing 'hooks' array`);
hasErrors = true;
} else {
// Validate each hook entry
for (let j = 0; j < hook.hooks.length; j++) {
if (validateHookEntry(hook.hooks[j], `Hook ${i}.hooks[${j}]`)) {
hasErrors = true;
}
}
}
totalMatchers++;
}
} else {
console.error('ERROR: hooks.json must be an object or array');
process.exit(1);
}
if (hasErrors) {
process.exit(1);
}
console.log(`Validated ${totalMatchers} hook matchers`);
}
validateHooks();

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
/**
* Validate selective-install manifests and profile/module relationships.
* Module paths are curated repo paths only. Generated/imported skill roots
* (~/.claude/skills/learned, etc.) are never in manifests.
*/
const fs = require('fs');
const path = require('path');
const Ajv = require('ajv');
const REPO_ROOT = path.join(__dirname, '../..');
const MODULES_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-modules.json');
const PROFILES_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-profiles.json');
const COMPONENTS_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-components.json');
const MODULES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-modules.schema.json');
const PROFILES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-profiles.schema.json');
const COMPONENTS_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-components.schema.json');
const COMPONENT_FAMILY_PREFIXES = {
baseline: 'baseline:',
language: 'lang:',
framework: 'framework:',
capability: 'capability:',
};
function readJson(filePath, label) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`Invalid JSON in ${label}: ${error.message}`);
}
}
function normalizeRelativePath(relativePath) {
return String(relativePath).replace(/\\/g, '/').replace(/\/+$/, '');
}
function validateSchema(ajv, schemaPath, data, label) {
const schema = readJson(schemaPath, `${label} schema`);
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid) {
for (const error of validate.errors) {
console.error(
`ERROR: ${label} schema: ${error.instancePath || '/'} ${error.message}`
);
}
return true;
}
return false;
}
function validateInstallManifests() {
if (!fs.existsSync(MODULES_MANIFEST_PATH) || !fs.existsSync(PROFILES_MANIFEST_PATH)) {
console.log('Install manifests not found, skipping validation');
process.exit(0);
}
let hasErrors = false;
let modulesData;
let profilesData;
let componentsData = { version: null, components: [] };
try {
modulesData = readJson(MODULES_MANIFEST_PATH, 'install-modules.json');
profilesData = readJson(PROFILES_MANIFEST_PATH, 'install-profiles.json');
if (fs.existsSync(COMPONENTS_MANIFEST_PATH)) {
componentsData = readJson(COMPONENTS_MANIFEST_PATH, 'install-components.json');
}
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
const ajv = new Ajv({ allErrors: true });
hasErrors = validateSchema(ajv, MODULES_SCHEMA_PATH, modulesData, 'install-modules.json') || hasErrors;
hasErrors = validateSchema(ajv, PROFILES_SCHEMA_PATH, profilesData, 'install-profiles.json') || hasErrors;
if (fs.existsSync(COMPONENTS_MANIFEST_PATH)) {
hasErrors = validateSchema(ajv, COMPONENTS_SCHEMA_PATH, componentsData, 'install-components.json') || hasErrors;
}
if (hasErrors) {
process.exit(1);
}
const modules = Array.isArray(modulesData.modules) ? modulesData.modules : [];
const moduleIds = new Set();
const claimedPaths = new Map();
for (const module of modules) {
if (moduleIds.has(module.id)) {
console.error(`ERROR: Duplicate install module id: ${module.id}`);
hasErrors = true;
}
moduleIds.add(module.id);
for (const dependency of module.dependencies) {
if (!moduleIds.has(dependency) && !modules.some(candidate => candidate.id === dependency)) {
console.error(`ERROR: Module ${module.id} depends on unknown module ${dependency}`);
hasErrors = true;
}
if (dependency === module.id) {
console.error(`ERROR: Module ${module.id} cannot depend on itself`);
hasErrors = true;
}
}
for (const relativePath of module.paths) {
const normalizedPath = normalizeRelativePath(relativePath);
const absolutePath = path.join(REPO_ROOT, normalizedPath);
// All module paths must exist; no optional/generated paths in manifests
if (!fs.existsSync(absolutePath)) {
console.error(
`ERROR: Module ${module.id} references missing path: ${normalizedPath}`
);
hasErrors = true;
}
if (claimedPaths.has(normalizedPath)) {
console.error(
`ERROR: Install path ${normalizedPath} is claimed by both ${claimedPaths.get(normalizedPath)} and ${module.id}`
);
hasErrors = true;
} else {
claimedPaths.set(normalizedPath, module.id);
}
}
}
const profiles = profilesData.profiles || {};
const components = Array.isArray(componentsData.components) ? componentsData.components : [];
const expectedProfileIds = ['core', 'developer', 'security', 'research', 'full'];
for (const profileId of expectedProfileIds) {
if (!profiles[profileId]) {
console.error(`ERROR: Missing required install profile: ${profileId}`);
hasErrors = true;
}
}
for (const [profileId, profile] of Object.entries(profiles)) {
const seenModules = new Set();
for (const moduleId of profile.modules) {
if (!moduleIds.has(moduleId)) {
console.error(
`ERROR: Profile ${profileId} references unknown module ${moduleId}`
);
hasErrors = true;
}
if (seenModules.has(moduleId)) {
console.error(
`ERROR: Profile ${profileId} contains duplicate module ${moduleId}`
);
hasErrors = true;
}
seenModules.add(moduleId);
}
}
if (profiles.full) {
const fullModules = new Set(profiles.full.modules);
for (const moduleId of moduleIds) {
if (!fullModules.has(moduleId)) {
console.error(`ERROR: full profile is missing module ${moduleId}`);
hasErrors = true;
}
}
}
const componentIds = new Set();
for (const component of components) {
if (componentIds.has(component.id)) {
console.error(`ERROR: Duplicate install component id: ${component.id}`);
hasErrors = true;
}
componentIds.add(component.id);
const expectedPrefix = COMPONENT_FAMILY_PREFIXES[component.family];
if (expectedPrefix && !component.id.startsWith(expectedPrefix)) {
console.error(
`ERROR: Component ${component.id} does not match expected ${component.family} prefix ${expectedPrefix}`
);
hasErrors = true;
}
const seenModules = new Set();
for (const moduleId of component.modules) {
if (!moduleIds.has(moduleId)) {
console.error(`ERROR: Component ${component.id} references unknown module ${moduleId}`);
hasErrors = true;
}
if (seenModules.has(moduleId)) {
console.error(`ERROR: Component ${component.id} contains duplicate module ${moduleId}`);
hasErrors = true;
}
seenModules.add(moduleId);
}
}
if (hasErrors) {
process.exit(1);
}
console.log(
`Validated ${modules.length} install modules, ${components.length} install components, and ${Object.keys(profiles).length} profiles`
);
}
validateInstallManifests();

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Prevent shipping user-specific absolute paths in public docs/skills/commands.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '../..');
const TARGETS = [
'README.md',
'skills',
'commands',
'agents',
'docs',
'.opencode/commands',
];
const BLOCK_PATTERNS = [
/\/Users\/affoon\b/g,
/C:\\Users\\affoon\b/gi,
];
function collectFiles(targetPath, out) {
if (!fs.existsSync(targetPath)) return;
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
out.push(targetPath);
return;
}
for (const entry of fs.readdirSync(targetPath)) {
if (entry === 'node_modules' || entry === '.git') continue;
collectFiles(path.join(targetPath, entry), out);
}
}
const files = [];
for (const target of TARGETS) {
collectFiles(path.join(ROOT, target), files);
}
let failures = 0;
for (const file of files) {
if (!/\.(md|json|js|ts|sh|toml|yml|yaml)$/i.test(file)) continue;
const content = fs.readFileSync(file, 'utf8');
for (const pattern of BLOCK_PATTERNS) {
const match = content.match(pattern);
if (match) {
console.error(`ERROR: personal path detected in ${path.relative(ROOT, file)}`);
failures += match.length;
break;
}
}
}
if (failures > 0) {
process.exit(1);
}
console.log('Validated: no personal absolute paths in shipped docs/skills/commands');

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* Validate rule markdown files
*/
const fs = require('fs');
const path = require('path');
const RULES_DIR = path.join(__dirname, '../../rules');
/**
* Recursively collect markdown rule files.
* Uses explicit traversal for portability across Node versions.
* @param {string} dir - Directory to scan
* @returns {string[]} Relative file paths from RULES_DIR
*/
function collectRuleFiles(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectRuleFiles(absolute));
continue;
}
if (entry.name.endsWith('.md')) {
files.push(path.relative(RULES_DIR, absolute));
}
// Non-markdown files are ignored.
}
return files;
}
function validateRules() {
if (!fs.existsSync(RULES_DIR)) {
console.log('No rules directory found, skipping validation');
process.exit(0);
}
const files = collectRuleFiles(RULES_DIR);
let hasErrors = false;
let validatedCount = 0;
for (const file of files) {
const filePath = path.join(RULES_DIR, file);
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) continue;
const content = fs.readFileSync(filePath, 'utf-8');
if (content.trim().length === 0) {
console.error(`ERROR: ${file} - Empty rule file`);
hasErrors = true;
continue;
}
validatedCount++;
} catch (err) {
console.error(`ERROR: ${file} - ${err.message}`);
hasErrors = true;
}
}
if (hasErrors) {
process.exit(1);
}
console.log(`Validated ${validatedCount} rule files`);
}
validateRules();

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* Validate curated skill directories (skills/ in repo).
* Scope: curated only. Learned/imported/evolved roots are out of scope.
* If skills/ does not exist, exit 0 (no curated skills to validate).
*/
const fs = require('fs');
const path = require('path');
const SKILLS_DIR = path.join(__dirname, '../../skills');
function validateSkills() {
if (!fs.existsSync(SKILLS_DIR)) {
console.log('No curated skills directory (skills/), skipping');
process.exit(0);
}
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
let hasErrors = false;
let validCount = 0;
for (const dir of dirs) {
const skillMd = path.join(SKILLS_DIR, dir, 'SKILL.md');
if (!fs.existsSync(skillMd)) {
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
hasErrors = true;
continue;
}
let content;
try {
content = fs.readFileSync(skillMd, 'utf-8');
} catch (err) {
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
hasErrors = true;
continue;
}
if (content.trim().length === 0) {
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
hasErrors = true;
continue;
}
validCount++;
}
if (hasErrors) {
process.exit(1);
}
console.log(`Validated ${validCount} skill directories`);
}
validateSkills();

View File

@@ -0,0 +1,468 @@
#!/usr/bin/env node
/**
* NanoClaw v2 — Barebones Agent REPL for Everything Claude Code
*
* Zero external dependencies. Session-aware REPL around `claude -p`.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const readline = require('readline');
const SESSION_NAME_RE = /^[a-zA-Z0-9][-a-zA-Z0-9]*$/;
const DEFAULT_MODEL = process.env.CLAW_MODEL || 'sonnet';
const DEFAULT_COMPACT_KEEP_TURNS = 20;
function isValidSessionName(name) {
return typeof name === 'string' && name.length > 0 && SESSION_NAME_RE.test(name);
}
function getClawDir() {
return path.join(os.homedir(), '.claude', 'claw');
}
function getSessionPath(name) {
return path.join(getClawDir(), `${name}.md`);
}
function listSessions(dir) {
const clawDir = dir || getClawDir();
if (!fs.existsSync(clawDir)) return [];
return fs.readdirSync(clawDir)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''));
}
function loadHistory(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch {
return '';
}
}
function appendTurn(filePath, role, content, timestamp) {
const ts = timestamp || new Date().toISOString();
const entry = `### [${ts}] ${role}\n${content}\n---\n`;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.appendFileSync(filePath, entry, 'utf8');
}
function normalizeSkillList(raw) {
if (!raw) return [];
if (Array.isArray(raw)) return raw.map(s => String(s).trim()).filter(Boolean);
return String(raw).split(',').map(s => s.trim()).filter(Boolean);
}
function loadECCContext(skillList) {
const requested = normalizeSkillList(skillList !== undefined ? skillList : process.env.CLAW_SKILLS || '');
if (requested.length === 0) return '';
const chunks = [];
for (const name of requested) {
const skillPath = path.join(process.cwd(), 'skills', name, 'SKILL.md');
try {
chunks.push(fs.readFileSync(skillPath, 'utf8'));
} catch {
// Skip missing skills silently to keep REPL usable.
}
}
return chunks.join('\n\n');
}
function buildPrompt(systemPrompt, history, userMessage) {
const parts = [];
if (systemPrompt) parts.push(`=== SYSTEM CONTEXT ===\n${systemPrompt}\n`);
if (history) parts.push(`=== CONVERSATION HISTORY ===\n${history}\n`);
parts.push(`=== USER MESSAGE ===\n${userMessage}`);
return parts.join('\n');
}
function askClaude(systemPrompt, history, userMessage, model) {
const fullPrompt = buildPrompt(systemPrompt, history, userMessage);
const args = [];
if (model) {
args.push('--model', model);
}
args.push('-p', fullPrompt);
const result = spawnSync('claude', args, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, CLAUDECODE: '' },
timeout: 300000,
});
if (result.error) {
return `[Error: ${result.error.message}]`;
}
if (result.status !== 0 && result.stderr) {
return `[Error: claude exited with code ${result.status}: ${result.stderr.trim()}]`;
}
return (result.stdout || '').trim();
}
function parseTurns(history) {
const turns = [];
const regex = /### \[([^\]]+)\] ([^\n]+)\n([\s\S]*?)\n---\n/g;
let match;
while ((match = regex.exec(history)) !== null) {
turns.push({ timestamp: match[1], role: match[2], content: match[3] });
}
return turns;
}
function estimateTokenCount(text) {
return Math.ceil((text || '').length / 4);
}
function getSessionMetrics(filePath) {
const history = loadHistory(filePath);
const turns = parseTurns(history);
const charCount = history.length;
const tokenEstimate = estimateTokenCount(history);
const userTurns = turns.filter(t => t.role === 'User').length;
const assistantTurns = turns.filter(t => t.role === 'Assistant').length;
return {
turns: turns.length,
userTurns,
assistantTurns,
charCount,
tokenEstimate,
};
}
function searchSessions(query, dir) {
const q = String(query || '').toLowerCase().trim();
if (!q) return [];
const sessionDir = dir || getClawDir();
const sessions = listSessions(sessionDir);
const results = [];
for (const name of sessions) {
const p = path.join(sessionDir, `${name}.md`);
const content = loadHistory(p);
if (!content) continue;
const idx = content.toLowerCase().indexOf(q);
if (idx >= 0) {
const start = Math.max(0, idx - 40);
const end = Math.min(content.length, idx + q.length + 40);
const snippet = content.slice(start, end).replace(/\n/g, ' ');
results.push({ session: name, snippet });
}
}
return results;
}
function compactSession(filePath, keepTurns = DEFAULT_COMPACT_KEEP_TURNS) {
const history = loadHistory(filePath);
if (!history) return false;
const turns = parseTurns(history);
if (turns.length <= keepTurns) return false;
const retained = turns.slice(-keepTurns);
const compactedHeader = `# NanoClaw Compaction\nCompacted at: ${new Date().toISOString()}\nRetained turns: ${keepTurns}/${turns.length}\n\n---\n`;
const compactedTurns = retained.map(t => `### [${t.timestamp}] ${t.role}\n${t.content}\n---\n`).join('');
fs.writeFileSync(filePath, compactedHeader + compactedTurns, 'utf8');
return true;
}
function exportSession(filePath, format, outputPath) {
const history = loadHistory(filePath);
const sessionName = path.basename(filePath, '.md');
const fmt = String(format || 'md').toLowerCase();
if (!history) {
return { ok: false, message: 'No session history to export.' };
}
const dir = path.dirname(filePath);
let out = outputPath;
if (!out) {
out = path.join(dir, `${sessionName}.export.${fmt === 'markdown' ? 'md' : fmt}`);
}
if (fmt === 'md' || fmt === 'markdown') {
fs.writeFileSync(out, history, 'utf8');
return { ok: true, path: out };
}
if (fmt === 'json') {
const turns = parseTurns(history);
fs.writeFileSync(out, JSON.stringify({ session: sessionName, turns }, null, 2), 'utf8');
return { ok: true, path: out };
}
if (fmt === 'txt' || fmt === 'text') {
const turns = parseTurns(history);
const txt = turns.map(t => `[${t.timestamp}] ${t.role}:\n${t.content}\n`).join('\n');
fs.writeFileSync(out, txt, 'utf8');
return { ok: true, path: out };
}
return { ok: false, message: `Unsupported export format: ${format}` };
}
function branchSession(currentSessionPath, newSessionName, targetDir = getClawDir()) {
if (!isValidSessionName(newSessionName)) {
return { ok: false, message: `Invalid branch session name: ${newSessionName}` };
}
const target = path.join(targetDir, `${newSessionName}.md`);
fs.mkdirSync(path.dirname(target), { recursive: true });
const content = loadHistory(currentSessionPath);
fs.writeFileSync(target, content, 'utf8');
return { ok: true, path: target, session: newSessionName };
}
function skillExists(skillName) {
const p = path.join(process.cwd(), 'skills', skillName, 'SKILL.md');
return fs.existsSync(p);
}
function handleClear(sessionPath) {
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
fs.writeFileSync(sessionPath, '', 'utf8');
console.log('Session cleared.');
}
function handleHistory(sessionPath) {
const history = loadHistory(sessionPath);
if (!history) {
console.log('(no history)');
return;
}
console.log(history);
}
function handleSessions(dir) {
const sessions = listSessions(dir);
if (sessions.length === 0) {
console.log('(no sessions)');
return;
}
console.log('Sessions:');
for (const s of sessions) {
console.log(` - ${s}`);
}
}
function handleHelp() {
console.log('NanoClaw REPL Commands:');
console.log(' /help Show this help');
console.log(' /clear Clear current session history');
console.log(' /history Print full conversation history');
console.log(' /sessions List saved sessions');
console.log(' /model [name] Show/set model');
console.log(' /load <skill-name> Load a skill into active context');
console.log(' /branch <session-name> Branch current session into a new session');
console.log(' /search <query> Search query across sessions');
console.log(' /compact Keep recent turns, compact older context');
console.log(' /export <md|json|txt> [path] Export current session');
console.log(' /metrics Show session metrics');
console.log(' exit Quit the REPL');
}
function main() {
const initialSessionName = process.env.CLAW_SESSION || 'default';
if (!isValidSessionName(initialSessionName)) {
console.error(`Error: Invalid session name "${initialSessionName}". Use alphanumeric characters and hyphens only.`);
process.exit(1);
}
fs.mkdirSync(getClawDir(), { recursive: true });
const state = {
sessionName: initialSessionName,
sessionPath: getSessionPath(initialSessionName),
model: DEFAULT_MODEL,
skills: normalizeSkillList(process.env.CLAW_SKILLS || ''),
};
let eccContext = loadECCContext(state.skills);
const loadedCount = state.skills.filter(skillExists).length;
console.log(`NanoClaw v2 — Session: ${state.sessionName}`);
console.log(`Model: ${state.model}`);
if (loadedCount > 0) {
console.log(`Loaded ${loadedCount} skill(s) as context.`);
}
console.log('Type /help for commands, exit to quit.\n');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const prompt = () => {
rl.question('claw> ', (input) => {
const line = input.trim();
if (!line) return prompt();
if (line === 'exit') {
console.log('Goodbye.');
rl.close();
return;
}
if (line === '/help') {
handleHelp();
return prompt();
}
if (line === '/clear') {
handleClear(state.sessionPath);
return prompt();
}
if (line === '/history') {
handleHistory(state.sessionPath);
return prompt();
}
if (line === '/sessions') {
handleSessions();
return prompt();
}
if (line.startsWith('/model')) {
const model = line.replace('/model', '').trim();
if (!model) {
console.log(`Current model: ${state.model}`);
} else {
state.model = model;
console.log(`Model set to: ${state.model}`);
}
return prompt();
}
if (line.startsWith('/load ')) {
const skill = line.replace('/load', '').trim();
if (!skill) {
console.log('Usage: /load <skill-name>');
return prompt();
}
if (!skillExists(skill)) {
console.log(`Skill not found: ${skill}`);
return prompt();
}
if (!state.skills.includes(skill)) {
state.skills.push(skill);
}
eccContext = loadECCContext(state.skills);
console.log(`Loaded skill: ${skill}`);
return prompt();
}
if (line.startsWith('/branch ')) {
const target = line.replace('/branch', '').trim();
const result = branchSession(state.sessionPath, target);
if (!result.ok) {
console.log(result.message);
return prompt();
}
state.sessionName = result.session;
state.sessionPath = result.path;
console.log(`Branched to session: ${state.sessionName}`);
return prompt();
}
if (line.startsWith('/search ')) {
const query = line.replace('/search', '').trim();
const matches = searchSessions(query);
if (matches.length === 0) {
console.log('(no matches)');
return prompt();
}
console.log(`Found ${matches.length} match(es):`);
for (const match of matches) {
console.log(`- ${match.session}: ${match.snippet}`);
}
return prompt();
}
if (line === '/compact') {
const changed = compactSession(state.sessionPath);
console.log(changed ? 'Session compacted.' : 'No compaction needed.');
return prompt();
}
if (line.startsWith('/export ')) {
const parts = line.split(/\s+/).filter(Boolean);
const format = parts[1];
const outputPath = parts[2];
if (!format) {
console.log('Usage: /export <md|json|txt> [path]');
return prompt();
}
const result = exportSession(state.sessionPath, format, outputPath);
if (!result.ok) {
console.log(result.message);
} else {
console.log(`Exported: ${result.path}`);
}
return prompt();
}
if (line === '/metrics') {
const m = getSessionMetrics(state.sessionPath);
console.log(`Session: ${state.sessionName}`);
console.log(`Model: ${state.model}`);
console.log(`Turns: ${m.turns} (user ${m.userTurns}, assistant ${m.assistantTurns})`);
console.log(`Chars: ${m.charCount}`);
console.log(`Estimated tokens: ${m.tokenEstimate}`);
return prompt();
}
// Regular message
const history = loadHistory(state.sessionPath);
appendTurn(state.sessionPath, 'User', line);
const response = askClaude(eccContext, history, line, state.model);
console.log(`\n${response}\n`);
appendTurn(state.sessionPath, 'Assistant', response);
prompt();
});
};
prompt();
}
module.exports = {
getClawDir,
getSessionPath,
listSessions,
loadHistory,
appendTurn,
loadECCContext,
buildPrompt,
askClaude,
isValidSessionName,
handleClear,
handleHistory,
handleSessions,
handleHelp,
parseTurns,
estimateTokenCount,
getSessionMetrics,
searchSessions,
compactSession,
exportSession,
branchSession,
main,
};
if (require.main === module) {
main();
}

View File

@@ -0,0 +1,330 @@
#!/usr/bin/env node
/**
* scripts/codemaps/generate.ts
*
* Codemap Generator for everything-claude-code (ECC)
*
* Scans the current working directory and generates architectural
* codemap documentation under docs/CODEMAPS/ as specified by the
* doc-updater agent.
*
* Usage:
* npx tsx scripts/codemaps/generate.ts [srcDir]
*
* Output:
* docs/CODEMAPS/INDEX.md
* docs/CODEMAPS/frontend.md
* docs/CODEMAPS/backend.md
* docs/CODEMAPS/database.md
* docs/CODEMAPS/integrations.md
* docs/CODEMAPS/workers.md
*/
import fs from 'fs';
import path from 'path';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const ROOT = process.cwd();
const SRC_DIR = process.argv[2] ? path.resolve(process.argv[2]) : ROOT;
const OUTPUT_DIR = path.join(ROOT, 'docs', 'CODEMAPS');
const TODAY = new Date().toISOString().split('T')[0];
// Patterns used to classify files into codemap areas
const AREA_PATTERNS: Record<string, RegExp[]> = {
frontend: [
/\/(app|pages|components|hooks|contexts|ui|views|layouts|styles)\//i,
/\.(tsx|jsx|css|scss|sass|less|vue|svelte)$/i,
],
backend: [
/\/(api|routes|controllers|middleware|server|services|handlers)\//i,
/\.(route|controller|handler|middleware|service)\.(ts|js)$/i,
],
database: [
/\/(models|schemas|migrations|prisma|drizzle|db|database|repositories)\//i,
/\.(model|schema|migration|seed)\.(ts|js)$/i,
/prisma\/schema\.prisma$/,
/schema\.sql$/,
],
integrations: [
/\/(integrations?|third-party|external|plugins?|adapters?|connectors?)\//i,
/\.(integration|adapter|connector)\.(ts|js)$/i,
],
workers: [
/\/(workers?|jobs?|queues?|tasks?|cron|background)\//i,
/\.(worker|job|queue|task|cron)\.(ts|js)$/i,
],
};
// ---------------------------------------------------------------------------
// File System Helpers
// ---------------------------------------------------------------------------
/** Recursively collect all files under a directory, skipping common noise dirs. */
function walkDir(dir: string, results: string[] = []): string[] {
const SKIP = new Set([
'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
'.turbo', 'coverage', '.cache', '__pycache__', '.venv', 'venv',
]);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (SKIP.has(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath, results);
} else if (entry.isFile()) {
results.push(fullPath);
}
}
return results;
}
/** Return path relative to ROOT, always using forward slashes. */
function rel(p: string): string {
return path.relative(ROOT, p).replace(/\\/g, '/');
}
// ---------------------------------------------------------------------------
// Analysis
// ---------------------------------------------------------------------------
interface AreaInfo {
name: string;
files: string[];
entryPoints: string[];
directories: string[];
}
function classifyFiles(allFiles: string[]): Record<string, AreaInfo> {
const areas: Record<string, AreaInfo> = {
frontend: { name: 'Frontend', files: [], entryPoints: [], directories: [] },
backend: { name: 'Backend/API', files: [], entryPoints: [], directories: [] },
database: { name: 'Database', files: [], entryPoints: [], directories: [] },
integrations: { name: 'Integrations', files: [], entryPoints: [], directories: [] },
workers: { name: 'Workers', files: [], entryPoints: [], directories: [] },
};
for (const file of allFiles) {
const relPath = rel(file);
for (const [area, patterns] of Object.entries(AREA_PATTERNS)) {
if (patterns.some((p) => p.test(relPath))) {
areas[area].files.push(relPath);
break;
}
}
}
// Derive unique directories and entry points per area
for (const area of Object.values(areas)) {
const dirs = new Set(area.files.map((f) => path.dirname(f)));
area.directories = [...dirs].sort();
area.entryPoints = area.files
.filter((f) => /index\.(ts|tsx|js|jsx)$/.test(f) || /main\.(ts|tsx|js|jsx)$/.test(f))
.slice(0, 10);
}
return areas;
}
/** Count lines in a file (returns 0 on error). */
function lineCount(p: string): number {
try {
const content = fs.readFileSync(p, 'utf8');
return content.split('\n').length;
} catch {
return 0;
}
}
/** Build a simple directory tree ASCII diagram (max 3 levels deep). */
function buildTree(dir: string, prefix = '', depth = 0): string {
if (depth > 2) return '';
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage']);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return '';
}
const dirs = entries.filter((e) => e.isDirectory() && !SKIP.has(e.name));
const files = entries.filter((e) => e.isFile());
let result = '';
const items = [...dirs, ...files];
items.forEach((entry, i) => {
const isLast = i === items.length - 1;
const connector = isLast ? '└── ' : '├── ';
result += `${prefix}${connector}${entry.name}\n`;
if (entry.isDirectory()) {
const newPrefix = prefix + (isLast ? ' ' : '│ ');
result += buildTree(path.join(dir, entry.name), newPrefix, depth + 1);
}
});
return result;
}
// ---------------------------------------------------------------------------
// Markdown Generators
// ---------------------------------------------------------------------------
function generateAreaDoc(areaKey: string, area: AreaInfo, allFiles: string[]): string {
const fileCount = area.files.length;
const totalLines = area.files.reduce((sum, f) => sum + lineCount(path.join(ROOT, f)), 0);
const entrySection = area.entryPoints.length > 0
? area.entryPoints.map((e) => `- \`${e}\``).join('\n')
: '- *(no index/main entry points detected)*';
const dirSection = area.directories.slice(0, 20)
.map((d) => `- \`${d}/\``)
.join('\n') || '- *(no dedicated directories detected)*';
const fileSection = area.files.slice(0, 30)
.map((f) => `| \`${f}\` | ${lineCount(path.join(ROOT, f))} |`)
.join('\n');
const moreFiles = area.files.length > 30
? `\n*...and ${area.files.length - 30} more files*`
: '';
return `# ${area.name} Codemap
**Last Updated:** ${TODAY}
**Total Files:** ${fileCount}
**Total Lines:** ${totalLines}
## Entry Points
${entrySection}
## Architecture
\`\`\`
${area.name} Directory Structure
${dirSection.replace(/- `/g, '').replace(/`\/$/gm, '/')}
\`\`\`
## Key Modules
| File | Lines |
|------|-------|
${fileSection}${moreFiles}
## Data Flow
> Detected from file patterns. Review individual files for detailed data flow.
## External Dependencies
> Run \`npx jsdoc2md src/**/*.ts\` to extract JSDoc and identify external dependencies.
## Related Areas
- [INDEX](./INDEX.md) — Full overview
- [Frontend](./frontend.md)
- [Backend/API](./backend.md)
- [Database](./database.md)
- [Integrations](./integrations.md)
- [Workers](./workers.md)
`;
}
function generateIndex(areas: Record<string, AreaInfo>, allFiles: string[]): string {
const totalFiles = allFiles.length;
const areaRows = Object.entries(areas)
.map(([key, area]) => `| [${area.name}](./${key}.md) | ${area.files.length} files | ${area.directories.slice(0, 3).map((d) => `\`${d}\``).join(', ') || '—'} |`)
.join('\n');
const topLevelTree = buildTree(SRC_DIR);
return `# Codebase Overview — CODEMAPS Index
**Last Updated:** ${TODAY}
**Root:** \`${rel(SRC_DIR) || '.'}\`
**Total Files Scanned:** ${totalFiles}
## Areas
| Area | Size | Key Directories |
|------|------|-----------------|
${areaRows}
## Repository Structure
\`\`\`
${rel(SRC_DIR) || path.basename(SRC_DIR)}/
${topLevelTree}\`\`\`
## How to Regenerate
\`\`\`bash
npx tsx scripts/codemaps/generate.ts # Regenerate codemaps
npx madge --image graph.svg src/ # Dependency graph (requires graphviz)
npx jsdoc2md src/**/*.ts # Extract JSDoc
\`\`\`
## Related Documentation
- [Frontend](./frontend.md) — UI components, pages, hooks
- [Backend/API](./backend.md) — API routes, controllers, middleware
- [Database](./database.md) — Models, schemas, migrations
- [Integrations](./integrations.md) — External services & adapters
- [Workers](./workers.md) — Background jobs, queues, cron tasks
`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main(): void {
console.log(`[generate.ts] Scanning: ${SRC_DIR}`);
console.log(`[generate.ts] Output: ${OUTPUT_DIR}`);
// Ensure output directory exists
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
// Walk the directory tree
const allFiles = walkDir(SRC_DIR);
console.log(`[generate.ts] Found ${allFiles.length} files`);
// Classify files into areas
const areas = classifyFiles(allFiles);
// Generate INDEX.md
const indexContent = generateIndex(areas, allFiles);
const indexPath = path.join(OUTPUT_DIR, 'INDEX.md');
fs.writeFileSync(indexPath, indexContent, 'utf8');
console.log(`[generate.ts] Written: ${rel(indexPath)}`);
// Generate per-area codemaps
for (const [key, area] of Object.entries(areas)) {
const content = generateAreaDoc(key, area, allFiles);
const outPath = path.join(OUTPUT_DIR, `${key}.md`);
fs.writeFileSync(outPath, content, 'utf8');
console.log(`[generate.ts] Written: ${rel(outPath)} (${area.files.length} files)`);
}
console.log('\n[generate.ts] Done! Codemaps written to docs/CODEMAPS/');
console.log('[generate.ts] Files generated:');
console.log(' docs/CODEMAPS/INDEX.md');
console.log(' docs/CODEMAPS/frontend.md');
console.log(' docs/CODEMAPS/backend.md');
console.log(' docs/CODEMAPS/database.md');
console.log(' docs/CODEMAPS/integrations.md');
console.log(' docs/CODEMAPS/workers.md');
}
main();

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
# ECC Codex Git Hook: pre-commit
# Blocks commits that add high-signal secrets.
if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PRECOMMIT:-0}" == "1" ]]; then
exit 0
fi
if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then
exit 0
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi
staged_files="$(git diff --cached --name-only --diff-filter=ACMR || true)"
if [[ -z "$staged_files" ]]; then
exit 0
fi
has_findings=0
scan_added_lines() {
local file="$1"
local name="$2"
local regex="$3"
local added_lines
local hits
added_lines="$(git diff --cached -U0 -- "$file" | awk '/^\+\+\+ /{next} /^\+/{print substr($0,2)}')"
if [[ -z "$added_lines" ]]; then
return 0
fi
if hits="$(printf '%s\n' "$added_lines" | rg -n --pcre2 "$regex" 2>/dev/null)"; then
printf '\n[ECC pre-commit] Potential secret detected (%s) in %s\n' "$name" "$file" >&2
printf '%s\n' "$hits" | head -n 3 >&2
has_findings=1
fi
}
while IFS= read -r file; do
[[ -z "$file" ]] && continue
case "$file" in
*.png|*.jpg|*.jpeg|*.gif|*.svg|*.pdf|*.zip|*.gz|*.lock|pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb)
continue
;;
esac
scan_added_lines "$file" "OpenAI key" 'sk-[A-Za-z0-9]{20,}'
scan_added_lines "$file" "GitHub classic token" 'ghp_[A-Za-z0-9]{36}'
scan_added_lines "$file" "GitHub fine-grained token" 'github_pat_[A-Za-z0-9_]{20,}'
scan_added_lines "$file" "AWS access key" 'AKIA[0-9A-Z]{16}'
scan_added_lines "$file" "private key block" '-----BEGIN (RSA|EC|OPENSSH|DSA|PRIVATE) KEY-----'
scan_added_lines "$file" "generic credential assignment" "(?i)\\b(api[_-]?key|secret|password|token)\\b\\s*[:=]\\s*['\\\"][^'\\\"]{12,}['\\\"]"
done <<< "$staged_files"
if [[ "$has_findings" -eq 1 ]]; then
cat >&2 <<'EOF'
[ECC pre-commit] Commit blocked to prevent secret leakage.
Fix:
1) Remove secrets from staged changes.
2) Move secrets to env vars or secret manager.
3) Re-stage and commit again.
Temporary bypass (not recommended):
ECC_SKIP_PRECOMMIT=1 git commit ...
EOF
exit 1
fi
exit 0

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
# ECC Codex Git Hook: pre-push
# Runs a lightweight verification flow before pushes.
if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PREPUSH:-0}" == "1" ]]; then
exit 0
fi
if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then
exit 0
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi
ran_any_check=0
log() {
printf '[ECC pre-push] %s\n' "$*"
}
fail() {
printf '[ECC pre-push] FAILED: %s\n' "$*" >&2
exit 1
}
detect_pm() {
if [[ -f "pnpm-lock.yaml" ]]; then
echo "pnpm"
elif [[ -f "bun.lockb" ]]; then
echo "bun"
elif [[ -f "yarn.lock" ]]; then
echo "yarn"
elif [[ -f "package-lock.json" ]]; then
echo "npm"
else
echo "npm"
fi
}
has_node_script() {
local script_name="$1"
node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); process.exit(p.scripts && p.scripts[process.argv[1]] ? 0 : 1)' "$script_name" >/dev/null 2>&1
}
run_node_script() {
local pm="$1"
local script_name="$2"
case "$pm" in
pnpm) pnpm run "$script_name" ;;
bun) bun run "$script_name" ;;
yarn) yarn "$script_name" ;;
npm) npm run "$script_name" ;;
*) npm run "$script_name" ;;
esac
}
if [[ -f "package.json" ]]; then
pm="$(detect_pm)"
log "Node project detected (package manager: $pm)"
for script_name in lint typecheck test build; do
if has_node_script "$script_name"; then
ran_any_check=1
log "Running: $script_name"
run_node_script "$pm" "$script_name" || fail "$script_name failed"
else
log "Skipping missing script: $script_name"
fi
done
if [[ "${ECC_PREPUSH_AUDIT:-0}" == "1" ]]; then
ran_any_check=1
log "Running dependency audit (ECC_PREPUSH_AUDIT=1)"
case "$pm" in
pnpm) pnpm audit --prod || fail "pnpm audit failed" ;;
bun) bun audit || fail "bun audit failed" ;;
yarn) yarn npm audit --recursive || fail "yarn audit failed" ;;
npm) npm audit --omit=dev || fail "npm audit failed" ;;
*) npm audit --omit=dev || fail "npm audit failed" ;;
esac
fi
fi
if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then
ran_any_check=1
log "Go project detected. Running: go test ./..."
go test ./... || fail "go test failed"
fi
if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then
if command -v pytest >/dev/null 2>&1; then
ran_any_check=1
log "Python project detected. Running: pytest -q"
pytest -q || fail "pytest failed"
else
log "Python project detected but pytest is not installed. Skipping."
fi
fi
if [[ "$ran_any_check" -eq 0 ]]; then
log "No supported checks found in this repository. Skipping."
else
log "Verification checks passed."
fi
exit 0

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env bash
set -euo pipefail
# ECC Codex global regression sanity check.
# Validates that global ~/.codex state matches expected ECC integration.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
CONFIG_FILE="$CODEX_HOME/config.toml"
AGENTS_FILE="$CODEX_HOME/AGENTS.md"
PROMPTS_DIR="$CODEX_HOME/prompts"
SKILLS_DIR="$CODEX_HOME/skills"
HOOKS_DIR_EXPECT="${ECC_GLOBAL_HOOKS_DIR:-$CODEX_HOME/git-hooks}"
failures=0
warnings=0
checks=0
ok() {
checks=$((checks + 1))
printf '[OK] %s\n' "$*"
}
warn() {
checks=$((checks + 1))
warnings=$((warnings + 1))
printf '[WARN] %s\n' "$*"
}
fail() {
checks=$((checks + 1))
failures=$((failures + 1))
printf '[FAIL] %s\n' "$*"
}
require_file() {
local file="$1"
local label="$2"
if [[ -f "$file" ]]; then
ok "$label exists ($file)"
else
fail "$label missing ($file)"
fi
}
check_config_pattern() {
local pattern="$1"
local label="$2"
if rg -n "$pattern" "$CONFIG_FILE" >/dev/null 2>&1; then
ok "$label"
else
fail "$label"
fi
}
check_config_absent() {
local pattern="$1"
local label="$2"
if rg -n "$pattern" "$CONFIG_FILE" >/dev/null 2>&1; then
fail "$label"
else
ok "$label"
fi
}
printf 'ECC GLOBAL SANITY CHECK\n'
printf 'Repo: %s\n' "$REPO_ROOT"
printf 'Codex home: %s\n\n' "$CODEX_HOME"
require_file "$CONFIG_FILE" "Global config.toml"
require_file "$AGENTS_FILE" "Global AGENTS.md"
if [[ -f "$AGENTS_FILE" ]]; then
if rg -n '^# Everything Claude Code \(ECC\) — Agent Instructions' "$AGENTS_FILE" >/dev/null 2>&1; then
ok "AGENTS contains ECC root instructions"
else
fail "AGENTS missing ECC root instructions"
fi
if rg -n '^# Codex Supplement \(From ECC \.codex/AGENTS\.md\)' "$AGENTS_FILE" >/dev/null 2>&1; then
ok "AGENTS contains ECC Codex supplement"
else
fail "AGENTS missing ECC Codex supplement"
fi
fi
if [[ -f "$CONFIG_FILE" ]]; then
check_config_pattern '^multi_agent\s*=\s*true' "multi_agent is enabled"
check_config_absent '^\s*collab\s*=' "deprecated collab flag is absent"
check_config_pattern '^persistent_instructions\s*=' "persistent_instructions is configured"
check_config_pattern '^\[profiles\.strict\]' "profiles.strict exists"
check_config_pattern '^\[profiles\.yolo\]' "profiles.yolo exists"
for section in \
'mcp_servers.github' \
'mcp_servers.memory' \
'mcp_servers.sequential-thinking' \
'mcp_servers.context7-mcp'
do
if rg -n "^\[$section\]" "$CONFIG_FILE" >/dev/null 2>&1; then
ok "MCP section [$section] exists"
else
fail "MCP section [$section] missing"
fi
done
if rg -n '^\[mcp_servers\.context7\]' "$CONFIG_FILE" >/dev/null 2>&1; then
warn "Duplicate [mcp_servers.context7] exists (context7-mcp is preferred)"
else
ok "No duplicate [mcp_servers.context7] section"
fi
fi
declare -a required_skills=(
api-design
article-writing
backend-patterns
coding-standards
content-engine
e2e-testing
eval-harness
frontend-patterns
frontend-slides
investor-materials
investor-outreach
market-research
security-review
strategic-compact
tdd-workflow
verification-loop
)
if [[ -d "$SKILLS_DIR" ]]; then
missing_skills=0
for skill in "${required_skills[@]}"; do
if [[ -d "$SKILLS_DIR/$skill" ]]; then
:
else
printf ' - missing skill: %s\n' "$skill"
missing_skills=$((missing_skills + 1))
fi
done
if [[ "$missing_skills" -eq 0 ]]; then
ok "All 16 ECC Codex skills are present"
else
fail "$missing_skills required skills are missing"
fi
else
fail "Skills directory missing ($SKILLS_DIR)"
fi
if [[ -f "$PROMPTS_DIR/ecc-prompts-manifest.txt" ]]; then
ok "Command prompts manifest exists"
else
fail "Command prompts manifest missing"
fi
if [[ -f "$PROMPTS_DIR/ecc-extension-prompts-manifest.txt" ]]; then
ok "Extension prompts manifest exists"
else
fail "Extension prompts manifest missing"
fi
command_prompts_count="$(find "$PROMPTS_DIR" -maxdepth 1 -type f -name 'ecc-*.md' 2>/dev/null | wc -l | tr -d ' ')"
if [[ "$command_prompts_count" -ge 43 ]]; then
ok "ECC prompts count is $command_prompts_count (expected >= 43)"
else
fail "ECC prompts count is $command_prompts_count (expected >= 43)"
fi
hooks_path="$(git config --global --get core.hooksPath || true)"
if [[ -n "$hooks_path" ]]; then
if [[ "$hooks_path" == "$HOOKS_DIR_EXPECT" ]]; then
ok "Global hooksPath is set to $HOOKS_DIR_EXPECT"
else
warn "Global hooksPath is $hooks_path (expected $HOOKS_DIR_EXPECT)"
fi
else
fail "Global hooksPath is not configured"
fi
if [[ -x "$HOOKS_DIR_EXPECT/pre-commit" ]]; then
ok "Global pre-commit hook is installed and executable"
else
fail "Global pre-commit hook missing or not executable"
fi
if [[ -x "$HOOKS_DIR_EXPECT/pre-push" ]]; then
ok "Global pre-push hook is installed and executable"
else
fail "Global pre-push hook missing or not executable"
fi
if command -v ecc-sync-codex >/dev/null 2>&1; then
ok "ecc-sync-codex command is in PATH"
else
warn "ecc-sync-codex is not in PATH"
fi
if command -v ecc-install-git-hooks >/dev/null 2>&1; then
ok "ecc-install-git-hooks command is in PATH"
else
warn "ecc-install-git-hooks is not in PATH"
fi
if command -v ecc-check-codex >/dev/null 2>&1; then
ok "ecc-check-codex command is in PATH"
else
warn "ecc-check-codex is not in PATH (this is expected before alias setup)"
fi
printf '\nSummary: checks=%d, warnings=%d, failures=%d\n' "$checks" "$warnings" "$failures"
if [[ "$failures" -eq 0 ]]; then
printf 'ECC GLOBAL SANITY: PASS\n'
else
printf 'ECC GLOBAL SANITY: FAIL\n'
exit 1
fi

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# Install ECC git safety hooks globally via core.hooksPath.
# Usage:
# ./scripts/codex/install-global-git-hooks.sh
# ./scripts/codex/install-global-git-hooks.sh --dry-run
MODE="apply"
if [[ "${1:-}" == "--dry-run" ]]; then
MODE="dry-run"
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
SOURCE_DIR="$REPO_ROOT/scripts/codex-git-hooks"
DEST_DIR="${ECC_GLOBAL_HOOKS_DIR:-$HOME/.codex/git-hooks}"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="$HOME/.codex/backups/git-hooks-$STAMP"
log() {
printf '[ecc-hooks] %s\n' "$*"
}
run_or_echo() {
if [[ "$MODE" == "dry-run" ]]; then
printf '[dry-run]'
printf ' %q' "$@"
printf '\n'
else
"$@"
fi
}
if [[ ! -d "$SOURCE_DIR" ]]; then
log "Missing source hooks directory: $SOURCE_DIR"
exit 1
fi
log "Mode: $MODE"
log "Source hooks: $SOURCE_DIR"
log "Global hooks destination: $DEST_DIR"
if [[ -d "$DEST_DIR" ]]; then
log "Backing up existing hooks directory to $BACKUP_DIR"
run_or_echo mkdir -p "$BACKUP_DIR"
run_or_echo cp -R "$DEST_DIR" "$BACKUP_DIR/hooks"
fi
run_or_echo mkdir -p "$DEST_DIR"
run_or_echo cp "$SOURCE_DIR/pre-commit" "$DEST_DIR/pre-commit"
run_or_echo cp "$SOURCE_DIR/pre-push" "$DEST_DIR/pre-push"
run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push"
if [[ "$MODE" == "apply" ]]; then
prev_hooks_path="$(git config --global core.hooksPath || true)"
if [[ -n "$prev_hooks_path" ]]; then
log "Previous global hooksPath: $prev_hooks_path"
fi
fi
run_or_echo git config --global core.hooksPath "$DEST_DIR"
log "Installed ECC global git hooks."
log "Disable per repo by creating .ecc-hooks-disable in project root."
log "Temporary bypass: ECC_SKIP_PRECOMMIT=1 or ECC_SKIP_PREPUSH=1"

View File

@@ -0,0 +1,304 @@
#!/usr/bin/env node
'use strict';
/**
* Merge ECC-recommended MCP servers into a Codex config.toml.
*
* Strategy: ADD-ONLY by default.
* - Parse the TOML to detect which mcp_servers.* sections exist.
* - Append raw TOML text for any missing servers (preserves existing file byte-for-byte).
* - Log warnings when an existing server's config differs from the ECC recommendation.
* - With --update-mcp, also replace existing ECC-managed servers.
*
* Uses the repo's package-manager abstraction (scripts/lib/package-manager.js)
* so MCP launcher commands respect the user's configured package manager.
*
* Usage:
* node merge-mcp-config.js <config.toml> [--dry-run] [--update-mcp]
*/
const fs = require('fs');
const path = require('path');
let TOML;
try {
TOML = require('@iarna/toml');
} catch {
console.error('[ecc-mcp] Missing dependency: @iarna/toml');
console.error('[ecc-mcp] Run: npm install (from the ECC repo root)');
process.exit(1);
}
// ---------------------------------------------------------------------------
// Package manager detection
// ---------------------------------------------------------------------------
let pmConfig;
try {
const { getPackageManager } = require(path.join(__dirname, '..', 'lib', 'package-manager.js'));
pmConfig = getPackageManager();
} catch {
// Fallback: if package-manager.js isn't available, default to npx
pmConfig = { name: 'npm', config: { name: 'npm', execCmd: 'npx' } };
}
// Yarn 1.x doesn't support `yarn dlx` — fall back to npx for classic Yarn.
let resolvedExecCmd = pmConfig.config.execCmd;
if (pmConfig.name === 'yarn' && resolvedExecCmd === 'yarn dlx') {
try {
const { execFileSync } = require('child_process');
const ver = execFileSync('yarn', ['--version'], { encoding: 'utf8', timeout: 5000 }).trim();
if (ver.startsWith('1.')) {
resolvedExecCmd = 'npx';
}
} catch {
// Can't detect version — keep yarn dlx and let it fail visibly
}
}
const PM_NAME = pmConfig.config.name || pmConfig.name;
const PM_EXEC = resolvedExecCmd; // e.g. "pnpm dlx", "npx", "bunx", "yarn dlx"
const PM_EXEC_PARTS = PM_EXEC.split(/\s+/); // ["pnpm", "dlx"] or ["npx"] or ["bunx"]
// ---------------------------------------------------------------------------
// ECC-recommended MCP servers
// ---------------------------------------------------------------------------
// GitHub bootstrap uses bash for token forwarding — this is intentionally
// shell-based regardless of package manager, since Codex runs on macOS/Linux.
const GH_BOOTSTRAP = `token=$(gh auth token 2>/dev/null || true); if [ -n "$token" ]; then export GITHUB_PERSONAL_ACCESS_TOKEN="$token"; fi; exec ${PM_EXEC} @modelcontextprotocol/server-github`;
/**
* Build a server spec with the detected package manager.
* Returns { fields, toml } where fields is for drift detection and
* toml is the raw text appended to the file.
*/
function dlxServer(name, pkg, extraFields, extraToml) {
const args = [...PM_EXEC_PARTS.slice(1), pkg];
const fields = { command: PM_EXEC_PARTS[0], args, ...extraFields };
const argsStr = JSON.stringify(args).replace(/,/g, ', ');
let toml = `[mcp_servers.${name}]\ncommand = "${PM_EXEC_PARTS[0]}"\nargs = ${argsStr}`;
if (extraToml) toml += '\n' + extraToml;
return { fields, toml };
}
/** Each entry: key = section name under mcp_servers, value = { toml, fields } */
const ECC_SERVERS = {
supabase: dlxServer('supabase', '@supabase/mcp-server-supabase@latest', { startup_timeout_sec: 20.0, tool_timeout_sec: 120.0 }, 'startup_timeout_sec = 20.0\ntool_timeout_sec = 120.0'),
playwright: dlxServer('playwright', '@playwright/mcp@latest'),
'context7-mcp': dlxServer('context7-mcp', '@upstash/context7-mcp'),
exa: {
fields: { url: 'https://mcp.exa.ai/mcp' },
toml: `[mcp_servers.exa]\nurl = "https://mcp.exa.ai/mcp"`
},
github: {
fields: { command: 'bash', args: ['-lc', GH_BOOTSTRAP] },
toml: `[mcp_servers.github]\ncommand = "bash"\nargs = ["-lc", ${JSON.stringify(GH_BOOTSTRAP)}]`
},
memory: dlxServer('memory', '@modelcontextprotocol/server-memory'),
'sequential-thinking': dlxServer('sequential-thinking', '@modelcontextprotocol/server-sequential-thinking')
};
// Append --features arg for supabase after dlxServer builds the base
ECC_SERVERS.supabase.fields.args.push('--features=account,docs,database,debugging,development,functions,storage,branching');
ECC_SERVERS.supabase.toml = ECC_SERVERS.supabase.toml.replace(/^(args = \[.*)\]$/m, '$1, "--features=account,docs,database,debugging,development,functions,storage,branching"]');
// Legacy section names that should be treated as an existing ECC server.
// e.g. old configs shipped [mcp_servers.context7] instead of [mcp_servers.context7-mcp].
const LEGACY_ALIASES = {
'context7-mcp': ['context7']
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function log(msg) {
console.log(`[ecc-mcp] ${msg}`);
}
function warn(msg) {
console.warn(`[ecc-mcp] WARNING: ${msg}`);
}
/** Shallow-compare two objects (one level deep, arrays by JSON). */
function configDiffers(existing, recommended) {
for (const key of Object.keys(recommended)) {
const a = existing[key];
const b = recommended[key];
if (Array.isArray(b)) {
if (JSON.stringify(a) !== JSON.stringify(b)) return true;
} else if (a !== b) {
return true;
}
}
return false;
}
/**
* Remove a TOML section and its key-value pairs from raw text.
* Matches the section header even if followed by inline comments or whitespace
* (e.g. `[mcp_servers.github] # comment`).
* Returns the text with the section removed.
*/
function removeSectionFromText(text, sectionHeader) {
const escaped = sectionHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const headerPattern = new RegExp(`^${escaped}(\\s*(#.*)?)?$`);
const lines = text.split('\n');
const result = [];
let skipping = false;
for (const line of lines) {
const trimmed = line.replace(/\r$/, '');
if (headerPattern.test(trimmed)) {
skipping = true;
continue;
}
if (skipping && /^\[/.test(trimmed)) {
skipping = false;
}
if (!skipping) {
result.push(line);
}
}
return result.join('\n');
}
/**
* Collect all TOML sub-section headers for a given server name.
* @iarna/toml nests subtables, so `[mcp_servers.supabase.env]` appears as
* `parsed.mcp_servers.supabase.env` (nested), NOT as a flat dotted key.
* Walk the nested object to find sub-objects that represent TOML sub-tables.
*/
function findSubSections(serverObj, prefix) {
const sections = [];
if (!serverObj || typeof serverObj !== 'object') return sections;
for (const key of Object.keys(serverObj)) {
const val = serverObj[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
const subPath = `${prefix}.${key}`;
sections.push(subPath);
sections.push(...findSubSections(val, subPath));
}
}
return sections;
}
/**
* Remove a server and all its sub-sections from raw TOML text.
* Uses findSubSections to walk the parsed nested object (not flat keys).
*/
function removeServerFromText(raw, serverName, existing) {
let result = removeSectionFromText(raw, `[mcp_servers.${serverName}]`);
const serverObj = existing[serverName];
if (serverObj) {
for (const sub of findSubSections(serverObj, serverName)) {
result = removeSectionFromText(result, `[mcp_servers.${sub}]`);
}
}
return result;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
const configPath = args.find(a => !a.startsWith('-'));
const dryRun = args.includes('--dry-run');
const updateMcp = args.includes('--update-mcp');
if (!configPath) {
console.error('Usage: merge-mcp-config.js <config.toml> [--dry-run] [--update-mcp]');
process.exit(1);
}
if (!fs.existsSync(configPath)) {
console.error(`[ecc-mcp] Config file not found: ${configPath}`);
process.exit(1);
}
log(`Package manager: ${PM_NAME} (exec: ${PM_EXEC})`);
let raw = fs.readFileSync(configPath, 'utf8');
let parsed;
try {
parsed = TOML.parse(raw);
} catch (err) {
console.error(`[ecc-mcp] Failed to parse ${configPath}: ${err.message}`);
process.exit(1);
}
const existing = parsed.mcp_servers || {};
const toAppend = [];
const toRemoveLog = [];
for (const [name, spec] of Object.entries(ECC_SERVERS)) {
const entry = existing[name];
const aliases = LEGACY_ALIASES[name] || [];
const legacyName = aliases.find(a => existing[a] && typeof existing[a].command === 'string');
// Prefer canonical entry over legacy alias
const hasCanonical = entry && typeof entry.command === 'string';
const resolvedEntry = hasCanonical ? entry : legacyName ? existing[legacyName] : null;
// For URL-based servers (exa), check for url field instead of command
const urlEntry = !resolvedEntry && entry && typeof entry.url === 'string' ? entry : null;
const finalEntry = resolvedEntry || urlEntry;
const resolvedLabel = hasCanonical ? name : legacyName || name;
if (finalEntry) {
if (updateMcp) {
// --update-mcp: remove existing section (and legacy alias), will re-add below
toRemoveLog.push(`mcp_servers.${resolvedLabel}`);
raw = removeServerFromText(raw, resolvedLabel, existing);
if (resolvedLabel !== name) {
raw = removeServerFromText(raw, name, existing);
}
toAppend.push(spec.toml);
} else {
// Add-only mode: skip, but warn about drift
if (legacyName && !hasCanonical) {
warn(`mcp_servers.${legacyName} is a legacy name for ${name} (run with --update-mcp to migrate)`);
} else if (configDiffers(finalEntry, spec.fields)) {
warn(`mcp_servers.${name} differs from ECC recommendation (run with --update-mcp to refresh)`);
} else {
log(` [ok] mcp_servers.${name}`);
}
}
} else {
log(` [add] mcp_servers.${name}`);
toAppend.push(spec.toml);
}
}
if (toAppend.length === 0) {
log('All ECC MCP servers already present. Nothing to do.');
return;
}
const appendText = '\n' + toAppend.join('\n\n') + '\n';
if (dryRun) {
if (toRemoveLog.length > 0) {
log('Dry run — would remove and re-add:');
for (const label of toRemoveLog) log(` [remove] ${label}`);
}
log('Dry run — would append:');
console.log(appendText);
return;
}
// Write: for add-only, append to preserve existing content byte-for-byte.
// For --update-mcp, we modified `raw` above, so write the full file + appended sections.
if (updateMcp) {
for (const label of toRemoveLog) log(` [update] ${label}`);
const cleaned = raw.replace(/\n+$/, '\n');
fs.writeFileSync(configPath, cleaned + appendText, 'utf8');
} else {
fs.appendFileSync(configPath, appendText, 'utf8');
}
log(`Done. ${toAppend.length} server(s) ${updateMcp ? 'updated' : 'added'}.`);
}
main();

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env node
const { buildDoctorReport } = require('./lib/install-lifecycle');
const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests');
function showHelp(exitCode = 0) {
console.log(`
Usage: node scripts/doctor.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--json]
Diagnose drift and missing managed files for ECC install-state in the current context.
`);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = argv.slice(2);
const parsed = {
targets: [],
json: false,
help: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target') {
parsed.targets.push(args[index + 1] || null);
index += 1;
} else if (arg === '--json') {
parsed.json = true;
} else if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return parsed;
}
function statusLabel(status) {
if (status === 'ok') {
return 'OK';
}
if (status === 'warning') {
return 'WARNING';
}
if (status === 'error') {
return 'ERROR';
}
return status.toUpperCase();
}
function printHuman(report) {
if (report.results.length === 0) {
console.log('No ECC install-state files found for the current home/project context.');
return;
}
console.log('Doctor report:\n');
for (const result of report.results) {
console.log(`- ${result.adapter.id}`);
console.log(` Status: ${statusLabel(result.status)}`);
console.log(` Install-state: ${result.installStatePath}`);
if (result.issues.length === 0) {
console.log(' Issues: none');
continue;
}
for (const issue of result.issues) {
console.log(` - [${issue.severity}] ${issue.code}: ${issue.message}`);
}
}
console.log(`\nSummary: checked=${report.summary.checkedCount}, ok=${report.summary.okCount}, warnings=${report.summary.warningCount}, errors=${report.summary.errorCount}`);
}
function main() {
try {
const options = parseArgs(process.argv);
if (options.help) {
showHelp(0);
}
const report = buildDoctorReport({
repoRoot: require('path').join(__dirname, '..'),
homeDir: process.env.HOME,
projectRoot: process.cwd(),
targets: options.targets,
});
const hasIssues = report.summary.errorCount > 0 || report.summary.warningCount > 0;
if (options.json) {
console.log(JSON.stringify(report, null, 2));
} else {
printHuman(report);
}
process.exitCode = hasIssues ? 1 : 0;
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env node
const { spawnSync } = require('child_process');
const path = require('path');
const { listAvailableLanguages } = require('./lib/install-executor');
const COMMANDS = {
install: {
script: 'install-apply.js',
description: 'Install ECC content into a supported target',
},
plan: {
script: 'install-plan.js',
description: 'Inspect selective-install manifests and resolved plans',
},
catalog: {
script: 'catalog.js',
description: 'Discover install profiles and component IDs',
},
'install-plan': {
script: 'install-plan.js',
description: 'Alias for plan',
},
'list-installed': {
script: 'list-installed.js',
description: 'Inspect install-state files for the current context',
},
doctor: {
script: 'doctor.js',
description: 'Diagnose missing or drifted ECC-managed files',
},
repair: {
script: 'repair.js',
description: 'Restore drifted or missing ECC-managed files',
},
status: {
script: 'status.js',
description: 'Query the ECC SQLite state store status summary',
},
sessions: {
script: 'sessions-cli.js',
description: 'List or inspect ECC sessions from the SQLite state store',
},
'session-inspect': {
script: 'session-inspect.js',
description: 'Emit canonical ECC session snapshots from dmux or Claude history targets',
},
uninstall: {
script: 'uninstall.js',
description: 'Remove ECC-managed files recorded in install-state',
},
};
const PRIMARY_COMMANDS = [
'install',
'plan',
'catalog',
'list-installed',
'doctor',
'repair',
'status',
'sessions',
'session-inspect',
'uninstall',
];
function showHelp(exitCode = 0) {
console.log(`
ECC selective-install CLI
Usage:
ecc <command> [args...]
ecc [install args...]
Commands:
${PRIMARY_COMMANDS.map(command => ` ${command.padEnd(15)} ${COMMANDS[command].description}`).join('\n')}
Compatibility:
ecc-install Legacy install entrypoint retained for existing flows
ecc [args...] Without a command, args are routed to "install"
ecc help <command> Show help for a specific command
Examples:
ecc typescript
ecc install --profile developer --target claude
ecc plan --profile core --target cursor
ecc catalog profiles
ecc catalog components --family language
ecc catalog show framework:nextjs
ecc list-installed --json
ecc doctor --target cursor
ecc repair --dry-run
ecc status --json
ecc sessions
ecc sessions session-active --json
ecc session-inspect claude:latest
ecc uninstall --target antigravity --dry-run
`);
process.exit(exitCode);
}
function resolveCommand(argv) {
const args = argv.slice(2);
if (args.length === 0) {
return { mode: 'help' };
}
const [firstArg, ...restArgs] = args;
if (firstArg === '--help' || firstArg === '-h') {
return { mode: 'help' };
}
if (firstArg === 'help') {
return {
mode: 'help-command',
command: restArgs[0] || null,
};
}
if (COMMANDS[firstArg]) {
return {
mode: 'command',
command: firstArg,
args: restArgs,
};
}
const knownLegacyLanguages = listAvailableLanguages();
const shouldTreatAsImplicitInstall = (
firstArg.startsWith('-')
|| knownLegacyLanguages.includes(firstArg)
);
if (!shouldTreatAsImplicitInstall) {
throw new Error(`Unknown command: ${firstArg}`);
}
return {
mode: 'command',
command: 'install',
args,
};
}
function runCommand(commandName, args) {
const command = COMMANDS[commandName];
if (!command) {
throw new Error(`Unknown command: ${commandName}`);
}
const result = spawnSync(
process.execPath,
[path.join(__dirname, command.script), ...args],
{
cwd: process.cwd(),
env: process.env,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
}
);
if (result.error) {
throw result.error;
}
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if (typeof result.status === 'number') {
return result.status;
}
if (result.signal) {
throw new Error(`Command "${commandName}" terminated by signal ${result.signal}`);
}
return 1;
}
function main() {
try {
const resolution = resolveCommand(process.argv);
if (resolution.mode === 'help') {
showHelp(0);
}
if (resolution.mode === 'help-command') {
if (!resolution.command) {
showHelp(0);
}
if (!COMMANDS[resolution.command]) {
throw new Error(`Unknown command: ${resolution.command}`);
}
process.exitCode = runCommand(resolution.command, ['--help']);
return;
}
process.exitCode = runCommand(resolution.command, resolution.args);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,512 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '..');
const CATEGORIES = [
'Tool Coverage',
'Context Efficiency',
'Quality Gates',
'Memory Persistence',
'Eval Coverage',
'Security Guardrails',
'Cost Efficiency',
];
function normalizeScope(scope) {
const value = (scope || 'repo').toLowerCase();
if (!['repo', 'hooks', 'skills', 'commands', 'agents'].includes(value)) {
throw new Error(`Invalid scope: ${scope}`);
}
return value;
}
function parseArgs(argv) {
const args = argv.slice(2);
const parsed = {
scope: 'repo',
format: 'text',
help: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
continue;
}
if (arg === '--format') {
parsed.format = (args[index + 1] || '').toLowerCase();
index += 1;
continue;
}
if (arg === '--scope') {
parsed.scope = normalizeScope(args[index + 1]);
index += 1;
continue;
}
if (arg.startsWith('--format=')) {
parsed.format = arg.split('=')[1].toLowerCase();
continue;
}
if (arg.startsWith('--scope=')) {
parsed.scope = normalizeScope(arg.split('=')[1]);
continue;
}
if (arg.startsWith('-')) {
throw new Error(`Unknown argument: ${arg}`);
}
parsed.scope = normalizeScope(arg);
}
if (!['text', 'json'].includes(parsed.format)) {
throw new Error(`Invalid format: ${parsed.format}. Use text or json.`);
}
return parsed;
}
function fileExists(relativePath) {
return fs.existsSync(path.join(REPO_ROOT, relativePath));
}
function readText(relativePath) {
return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8');
}
function countFiles(relativeDir, extension) {
const dirPath = path.join(REPO_ROOT, relativeDir);
if (!fs.existsSync(dirPath)) {
return 0;
}
const stack = [dirPath];
let count = 0;
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const nextPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(nextPath);
} else if (!extension || entry.name.endsWith(extension)) {
count += 1;
}
}
}
return count;
}
function safeRead(relativePath) {
try {
return readText(relativePath);
} catch (_error) {
return '';
}
}
function getChecks() {
const packageJson = JSON.parse(readText('package.json'));
const commandPrimary = safeRead('commands/harness-audit.md').trim();
const commandParity = safeRead('.opencode/commands/harness-audit.md').trim();
const hooksJson = safeRead('hooks/hooks.json');
return [
{
id: 'tool-hooks-config',
category: 'Tool Coverage',
points: 2,
scopes: ['repo', 'hooks'],
path: 'hooks/hooks.json',
description: 'Hook configuration file exists',
pass: fileExists('hooks/hooks.json'),
fix: 'Create hooks/hooks.json and define baseline hook events.',
},
{
id: 'tool-hooks-impl-count',
category: 'Tool Coverage',
points: 2,
scopes: ['repo', 'hooks'],
path: 'scripts/hooks/',
description: 'At least 8 hook implementation scripts exist',
pass: countFiles('scripts/hooks', '.js') >= 8,
fix: 'Add missing hook implementations in scripts/hooks/.',
},
{
id: 'tool-agent-count',
category: 'Tool Coverage',
points: 2,
scopes: ['repo', 'agents'],
path: 'agents/',
description: 'At least 10 agent definitions exist',
pass: countFiles('agents', '.md') >= 10,
fix: 'Add or restore agent definitions under agents/.',
},
{
id: 'tool-skill-count',
category: 'Tool Coverage',
points: 2,
scopes: ['repo', 'skills'],
path: 'skills/',
description: 'At least 20 skill definitions exist',
pass: countFiles('skills', 'SKILL.md') >= 20,
fix: 'Add missing skill directories with SKILL.md definitions.',
},
{
id: 'tool-command-parity',
category: 'Tool Coverage',
points: 2,
scopes: ['repo', 'commands'],
path: '.opencode/commands/harness-audit.md',
description: 'Harness-audit command parity exists between primary and OpenCode command docs',
pass: commandPrimary.length > 0 && commandPrimary === commandParity,
fix: 'Sync commands/harness-audit.md and .opencode/commands/harness-audit.md.',
},
{
id: 'context-strategic-compact',
category: 'Context Efficiency',
points: 3,
scopes: ['repo', 'skills'],
path: 'skills/strategic-compact/SKILL.md',
description: 'Strategic compaction guidance is present',
pass: fileExists('skills/strategic-compact/SKILL.md'),
fix: 'Add strategic context compaction guidance at skills/strategic-compact/SKILL.md.',
},
{
id: 'context-suggest-compact-hook',
category: 'Context Efficiency',
points: 3,
scopes: ['repo', 'hooks'],
path: 'scripts/hooks/suggest-compact.js',
description: 'Suggest-compact automation hook exists',
pass: fileExists('scripts/hooks/suggest-compact.js'),
fix: 'Implement scripts/hooks/suggest-compact.js for context pressure hints.',
},
{
id: 'context-model-route',
category: 'Context Efficiency',
points: 2,
scopes: ['repo', 'commands'],
path: 'commands/model-route.md',
description: 'Model routing command exists',
pass: fileExists('commands/model-route.md'),
fix: 'Add model-route command guidance in commands/model-route.md.',
},
{
id: 'context-token-doc',
category: 'Context Efficiency',
points: 2,
scopes: ['repo'],
path: 'docs/token-optimization.md',
description: 'Token optimization documentation exists',
pass: fileExists('docs/token-optimization.md'),
fix: 'Add docs/token-optimization.md with concrete context-cost controls.',
},
{
id: 'quality-test-runner',
category: 'Quality Gates',
points: 3,
scopes: ['repo'],
path: 'tests/run-all.js',
description: 'Central test runner exists',
pass: fileExists('tests/run-all.js'),
fix: 'Add tests/run-all.js to enforce complete suite execution.',
},
{
id: 'quality-ci-validations',
category: 'Quality Gates',
points: 3,
scopes: ['repo'],
path: 'package.json',
description: 'Test script runs validator chain before tests',
pass: typeof packageJson.scripts?.test === 'string' && packageJson.scripts.test.includes('validate-commands.js') && packageJson.scripts.test.includes('tests/run-all.js'),
fix: 'Update package.json test script to run validators plus tests/run-all.js.',
},
{
id: 'quality-hook-tests',
category: 'Quality Gates',
points: 2,
scopes: ['repo', 'hooks'],
path: 'tests/hooks/hooks.test.js',
description: 'Hook coverage test file exists',
pass: fileExists('tests/hooks/hooks.test.js'),
fix: 'Add tests/hooks/hooks.test.js for hook behavior validation.',
},
{
id: 'quality-doctor-script',
category: 'Quality Gates',
points: 2,
scopes: ['repo'],
path: 'scripts/doctor.js',
description: 'Installation drift doctor script exists',
pass: fileExists('scripts/doctor.js'),
fix: 'Add scripts/doctor.js for install-state integrity checks.',
},
{
id: 'memory-hooks-dir',
category: 'Memory Persistence',
points: 4,
scopes: ['repo', 'hooks'],
path: 'hooks/memory-persistence/',
description: 'Memory persistence hooks directory exists',
pass: fileExists('hooks/memory-persistence'),
fix: 'Add hooks/memory-persistence with lifecycle hook definitions.',
},
{
id: 'memory-session-hooks',
category: 'Memory Persistence',
points: 4,
scopes: ['repo', 'hooks'],
path: 'scripts/hooks/session-start.js',
description: 'Session start/end persistence scripts exist',
pass: fileExists('scripts/hooks/session-start.js') && fileExists('scripts/hooks/session-end.js'),
fix: 'Implement scripts/hooks/session-start.js and scripts/hooks/session-end.js.',
},
{
id: 'memory-learning-skill',
category: 'Memory Persistence',
points: 2,
scopes: ['repo', 'skills'],
path: 'skills/continuous-learning-v2/SKILL.md',
description: 'Continuous learning v2 skill exists',
pass: fileExists('skills/continuous-learning-v2/SKILL.md'),
fix: 'Add skills/continuous-learning-v2/SKILL.md for memory evolution flow.',
},
{
id: 'eval-skill',
category: 'Eval Coverage',
points: 4,
scopes: ['repo', 'skills'],
path: 'skills/eval-harness/SKILL.md',
description: 'Eval harness skill exists',
pass: fileExists('skills/eval-harness/SKILL.md'),
fix: 'Add skills/eval-harness/SKILL.md for pass/fail regression evaluation.',
},
{
id: 'eval-commands',
category: 'Eval Coverage',
points: 4,
scopes: ['repo', 'commands'],
path: 'commands/eval.md',
description: 'Eval and verification commands exist',
pass: fileExists('commands/eval.md') && fileExists('commands/verify.md') && fileExists('commands/checkpoint.md'),
fix: 'Add eval/checkpoint/verify commands to standardize verification loops.',
},
{
id: 'eval-tests-presence',
category: 'Eval Coverage',
points: 2,
scopes: ['repo'],
path: 'tests/',
description: 'At least 10 test files exist',
pass: countFiles('tests', '.test.js') >= 10,
fix: 'Increase automated test coverage across scripts/hooks/lib.',
},
{
id: 'security-review-skill',
category: 'Security Guardrails',
points: 3,
scopes: ['repo', 'skills'],
path: 'skills/security-review/SKILL.md',
description: 'Security review skill exists',
pass: fileExists('skills/security-review/SKILL.md'),
fix: 'Add skills/security-review/SKILL.md for security checklist coverage.',
},
{
id: 'security-agent',
category: 'Security Guardrails',
points: 3,
scopes: ['repo', 'agents'],
path: 'agents/security-reviewer.md',
description: 'Security reviewer agent exists',
pass: fileExists('agents/security-reviewer.md'),
fix: 'Add agents/security-reviewer.md for delegated security audits.',
},
{
id: 'security-prompt-hook',
category: 'Security Guardrails',
points: 2,
scopes: ['repo', 'hooks'],
path: 'hooks/hooks.json',
description: 'Hooks include prompt submission guardrail event references',
pass: hooksJson.includes('beforeSubmitPrompt') || hooksJson.includes('PreToolUse'),
fix: 'Add prompt/tool preflight security guards in hooks/hooks.json.',
},
{
id: 'security-scan-command',
category: 'Security Guardrails',
points: 2,
scopes: ['repo', 'commands'],
path: 'commands/security-scan.md',
description: 'Security scan command exists',
pass: fileExists('commands/security-scan.md'),
fix: 'Add commands/security-scan.md with scan and remediation workflow.',
},
{
id: 'cost-skill',
category: 'Cost Efficiency',
points: 4,
scopes: ['repo', 'skills'],
path: 'skills/cost-aware-llm-pipeline/SKILL.md',
description: 'Cost-aware LLM skill exists',
pass: fileExists('skills/cost-aware-llm-pipeline/SKILL.md'),
fix: 'Add skills/cost-aware-llm-pipeline/SKILL.md for budget-aware routing.',
},
{
id: 'cost-doc',
category: 'Cost Efficiency',
points: 3,
scopes: ['repo'],
path: 'docs/token-optimization.md',
description: 'Cost optimization documentation exists',
pass: fileExists('docs/token-optimization.md'),
fix: 'Create docs/token-optimization.md with target settings and tradeoffs.',
},
{
id: 'cost-model-route-command',
category: 'Cost Efficiency',
points: 3,
scopes: ['repo', 'commands'],
path: 'commands/model-route.md',
description: 'Model route command exists for complexity-aware routing',
pass: fileExists('commands/model-route.md'),
fix: 'Add commands/model-route.md and route policies for cheap-default execution.',
},
];
}
function summarizeCategoryScores(checks) {
const scores = {};
for (const category of CATEGORIES) {
const inCategory = checks.filter(check => check.category === category);
const max = inCategory.reduce((sum, check) => sum + check.points, 0);
const earned = inCategory
.filter(check => check.pass)
.reduce((sum, check) => sum + check.points, 0);
const normalized = max === 0 ? 0 : Math.round((earned / max) * 10);
scores[category] = {
score: normalized,
earned,
max,
};
}
return scores;
}
function buildReport(scope) {
const checks = getChecks().filter(check => check.scopes.includes(scope));
const categoryScores = summarizeCategoryScores(checks);
const maxScore = checks.reduce((sum, check) => sum + check.points, 0);
const overallScore = checks
.filter(check => check.pass)
.reduce((sum, check) => sum + check.points, 0);
const failedChecks = checks.filter(check => !check.pass);
const topActions = failedChecks
.sort((left, right) => right.points - left.points)
.slice(0, 3)
.map(check => ({
action: check.fix,
path: check.path,
category: check.category,
points: check.points,
}));
return {
scope,
deterministic: true,
rubric_version: '2026-03-16',
overall_score: overallScore,
max_score: maxScore,
categories: categoryScores,
checks: checks.map(check => ({
id: check.id,
category: check.category,
points: check.points,
path: check.path,
description: check.description,
pass: check.pass,
})),
top_actions: topActions,
};
}
function printText(report) {
console.log(`Harness Audit (${report.scope}): ${report.overall_score}/${report.max_score}`);
console.log('');
for (const category of CATEGORIES) {
const data = report.categories[category];
if (!data || data.max === 0) {
continue;
}
console.log(`- ${category}: ${data.score}/10 (${data.earned}/${data.max} pts)`);
}
const failed = report.checks.filter(check => !check.pass);
console.log('');
console.log(`Checks: ${report.checks.length} total, ${failed.length} failing`);
if (failed.length > 0) {
console.log('');
console.log('Top 3 Actions:');
report.top_actions.forEach((action, index) => {
console.log(`${index + 1}) [${action.category}] ${action.action} (${action.path})`);
});
}
}
function showHelp(exitCode = 0) {
console.log(`
Usage: node scripts/harness-audit.js [scope] [--scope <repo|hooks|skills|commands|agents>] [--format <text|json>]
Deterministic harness audit based on explicit file/rule checks.
`);
process.exit(exitCode);
}
function main() {
try {
const args = parseArgs(process.argv);
if (args.help) {
showHelp(0);
return;
}
const report = buildReport(args.scope);
if (args.format === 'json') {
console.log(JSON.stringify(report, null, 2));
} else {
printText(report);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
buildReport,
parseArgs,
};

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Auto-Tmux Dev Hook - Start dev servers in tmux/cmd automatically
*
* macOS/Linux: Runs dev server in a named tmux session (non-blocking).
* Falls back to original command if tmux is not installed.
* Windows: Opens dev server in a new cmd window (non-blocking).
*
* Runs before Bash tool use. If command is a dev server (npm run dev, pnpm dev, yarn dev, bun run dev),
* transforms it to run in a detached session.
*
* Benefits:
* - Dev server runs detached (doesn't block Claude Code)
* - Session persists (can run `tmux capture-pane -t <session> -p` to see logs on Unix)
* - Session name matches project directory (allows multiple projects simultaneously)
*
* Session management (Unix):
* - Checks tmux availability before transforming
* - Kills any existing session with the same name (clean restart)
* - Creates new detached session
* - Reports session name and how to view logs
*
* Session management (Windows):
* - Opens new cmd window with descriptive title
* - Allows multiple dev servers to run simultaneously
*/
const path = require('path');
const { spawnSync } = require('child_process');
const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
let input;
try {
input = JSON.parse(data);
const cmd = input.tool_input?.command || '';
// Detect dev server commands: npm run dev, pnpm dev, yarn dev, bun run dev
// Use word boundary (\b) to avoid matching partial commands
const devServerRegex = /(npm run dev\b|pnpm( run)? dev\b|yarn dev\b|bun run dev\b)/;
if (devServerRegex.test(cmd)) {
// Get session name from current directory basename, sanitize for shell safety
// e.g., /home/user/Portfolio → "Portfolio", /home/user/my-app-v2 → "my-app-v2"
const rawName = path.basename(process.cwd());
// Replace non-alphanumeric characters (except - and _) with underscore to prevent shell injection
const sessionName = rawName.replace(/[^a-zA-Z0-9_-]/g, '_') || 'dev';
if (process.platform === 'win32') {
// Windows: open in a new cmd window (non-blocking)
// Escape double quotes in cmd for cmd /k syntax
const escapedCmd = cmd.replace(/"/g, '""');
input.tool_input.command = `start "DevServer-${sessionName}" cmd /k "${escapedCmd}"`;
} else {
// Unix (macOS/Linux): Check tmux is available before transforming
const tmuxCheck = spawnSync('which', ['tmux'], { encoding: 'utf8' });
if (tmuxCheck.status === 0) {
// Escape single quotes for shell safety: 'text' -> 'text'\''text'
const escapedCmd = cmd.replace(/'/g, "'\\''");
// Build the transformed command:
// 1. Kill existing session (silent if doesn't exist)
// 2. Create new detached session with the dev command
// 3. Echo confirmation message with instructions for viewing logs
const transformedCmd = `SESSION="${sessionName}"; tmux kill-session -t "$SESSION" 2>/dev/null || true; tmux new-session -d -s "$SESSION" '${escapedCmd}' && echo "[Hook] Dev server started in tmux session '${sessionName}'. View logs: tmux capture-pane -t ${sessionName} -p -S -100"`;
input.tool_input.command = transformedCmd;
}
// else: tmux not found, pass through original command unchanged
}
}
process.stdout.write(JSON.stringify(input));
} catch {
// Invalid input — pass through original data unchanged
process.stdout.write(data);
}
process.exit(0);
});

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Stop Hook: Check for console.log statements in modified files
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after each response and checks if any modified JavaScript/TypeScript
* files contain console.log statements. Provides warnings to help developers
* remember to remove debug statements before committing.
*
* Exclusions: test files, config files, and scripts/ directory (where
* console.log is often intentional).
*/
const fs = require('fs');
const { isGitRepo, getGitModifiedFiles, readFile, log } = require('../lib/utils');
// Files where console.log is expected and should not trigger warnings
const EXCLUDED_PATTERNS = [
/\.test\.[jt]sx?$/,
/\.spec\.[jt]sx?$/,
/\.config\.[jt]s$/,
/scripts\//,
/__tests__\//,
/__mocks__\//,
];
const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
if (!isGitRepo()) {
process.stdout.write(data);
process.exit(0);
}
const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$'])
.filter(f => fs.existsSync(f))
.filter(f => !EXCLUDED_PATTERNS.some(pattern => pattern.test(f)));
let hasConsole = false;
for (const file of files) {
const content = readFile(file);
if (content && content.includes('console.log')) {
log(`[Hook] WARNING: console.log found in ${file}`);
hasConsole = true;
}
}
if (hasConsole) {
log('[Hook] Remove console.log statements before committing');
}
} catch (err) {
log(`[Hook] check-console-log error: ${err.message}`);
}
// Always output the original data
process.stdout.write(data);
process.exit(0);
});

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env node
'use strict';
const { isHookEnabled } = require('../lib/hook-flags');
const [, , hookId, profilesCsv] = process.argv;
if (!hookId) {
process.stdout.write('yes');
process.exit(0);
}
process.stdout.write(isHookEnabled(hookId, { profiles: profilesCsv }) ? 'yes' : 'no');

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* Config Protection Hook
*
* Blocks modifications to linter/formatter config files.
* Agents frequently modify these to make checks pass instead of fixing
* the actual code. This hook steers the agent back to fixing the source.
*
* Exit codes:
* 0 = allow (not a config file)
* 2 = block (config file modification attempted)
*/
'use strict';
const path = require('path');
const MAX_STDIN = 1024 * 1024;
let raw = '';
const PROTECTED_FILES = new Set([
// ESLint (legacy + v9 flat config, JS/TS/MJS/CJS)
'.eslintrc',
'.eslintrc.js',
'.eslintrc.cjs',
'.eslintrc.json',
'.eslintrc.yml',
'.eslintrc.yaml',
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs',
'eslint.config.ts',
'eslint.config.mts',
'eslint.config.cts',
// Prettier (all config variants including ESM)
'.prettierrc',
'.prettierrc.js',
'.prettierrc.cjs',
'.prettierrc.json',
'.prettierrc.yml',
'.prettierrc.yaml',
'prettier.config.js',
'prettier.config.cjs',
'prettier.config.mjs',
// Biome
'biome.json',
'biome.jsonc',
// Ruff (Python)
'.ruff.toml',
'ruff.toml',
// Note: pyproject.toml is intentionally NOT included here because it
// contains project metadata alongside linter config. Blocking all edits
// to pyproject.toml would prevent legitimate dependency changes.
// Shell / Style / Markdown
'.shellcheckrc',
'.stylelintrc',
'.stylelintrc.json',
'.stylelintrc.yml',
'.markdownlint.json',
'.markdownlint.yaml',
'.markdownlintrc',
]);
function parseInput(inputOrRaw) {
if (typeof inputOrRaw === 'string') {
try {
return inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {};
} catch {
return {};
}
}
return inputOrRaw && typeof inputOrRaw === 'object' ? inputOrRaw : {};
}
/**
* Exportable run() for in-process execution via run-with-flags.js.
* Avoids the ~50-100ms spawnSync overhead when available.
*/
function run(inputOrRaw, options = {}) {
if (options.truncated) {
return {
exitCode: 2,
stderr:
`BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. ` +
'Refusing to bypass config-protection on a truncated payload. ' +
'Retry with a smaller edit or disable the config-protection hook temporarily.'
};
}
const input = parseInput(inputOrRaw);
const filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
if (!filePath) return { exitCode: 0 };
const basename = path.basename(filePath);
if (PROTECTED_FILES.has(basename)) {
return {
exitCode: 2,
stderr:
`BLOCKED: Modifying ${basename} is not allowed. ` +
'Fix the source code to satisfy linter/formatter rules instead of ' +
'weakening the config. If this is a legitimate config change, ' +
'disable the config-protection hook temporarily.',
};
}
return { exitCode: 0 };
}
module.exports = { run };
// Stdin fallback for spawnSync execution
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
});
if (result.stderr) {
process.stderr.write(result.stderr + '\n');
}
if (result.exitCode === 2) {
process.exit(2);
}
process.stdout.write(raw);
});

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Cost Tracker Hook
*
* Appends lightweight session usage metrics to ~/.claude/metrics/costs.jsonl.
*/
'use strict';
const path = require('path');
const {
ensureDir,
appendFile,
getClaudeDir,
} = require('../lib/utils');
const MAX_STDIN = 1024 * 1024;
let raw = '';
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function estimateCost(model, inputTokens, outputTokens) {
// Approximate per-1M-token blended rates. Conservative defaults.
const table = {
'haiku': { in: 0.8, out: 4.0 },
'sonnet': { in: 3.0, out: 15.0 },
'opus': { in: 15.0, out: 75.0 },
};
const normalized = String(model || '').toLowerCase();
let rates = table.sonnet;
if (normalized.includes('haiku')) rates = table.haiku;
if (normalized.includes('opus')) rates = table.opus;
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
return Math.round(cost * 1e6) / 1e6;
}
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = raw.trim() ? JSON.parse(raw) : {};
const usage = input.usage || input.token_usage || {};
const inputTokens = toNumber(usage.input_tokens || usage.prompt_tokens || 0);
const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0);
const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
const sessionId = String(process.env.CLAUDE_SESSION_ID || 'default');
const metricsDir = path.join(getClaudeDir(), 'metrics');
ensureDir(metricsDir);
const row = {
timestamp: new Date().toISOString(),
session_id: sessionId,
model,
input_tokens: inputTokens,
output_tokens: outputTokens,
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens),
};
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
} catch {
// Keep hook non-blocking.
}
process.stdout.write(raw);
});

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Desktop Notification Hook (Stop)
*
* Sends a native desktop notification with the task summary when Claude
* finishes responding. Currently supports macOS (osascript); other
* platforms exit silently. Windows (PowerShell) and Linux (notify-send)
* support is planned.
*
* Hook ID : stop:desktop-notify
* Profiles: standard, strict
*/
'use strict';
const { spawnSync } = require('child_process');
const { isMacOS, log } = require('../lib/utils');
const TITLE = 'Claude Code';
const MAX_BODY_LENGTH = 100;
/**
* Extract a short summary from the last assistant message.
* Takes the first non-empty line and truncates to MAX_BODY_LENGTH chars.
*/
function extractSummary(message) {
if (!message || typeof message !== 'string') return 'Done';
const firstLine = message
.split('\n')
.map(l => l.trim())
.find(l => l.length > 0);
if (!firstLine) return 'Done';
return firstLine.length > MAX_BODY_LENGTH
? `${firstLine.slice(0, MAX_BODY_LENGTH)}...`
: firstLine;
}
/**
* Send a macOS notification via osascript.
* AppleScript strings do not support backslash escapes, so we replace
* double quotes with curly quotes and strip backslashes before embedding.
*/
function notifyMacOS(title, body) {
const safeBody = body.replace(/\\/g, '').replace(/"/g, '\u201C');
const safeTitle = title.replace(/\\/g, '').replace(/"/g, '\u201C');
const script = `display notification "${safeBody}" with title "${safeTitle}"`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'ignore', timeout: 5000 });
if (result.error || result.status !== 0) {
log(`[DesktopNotify] osascript failed: ${result.error ? result.error.message : `exit ${result.status}`}`);
}
}
// TODO: future platform support
// function notifyWindows(title, body) { ... }
// function notifyLinux(title, body) { ... }
/**
* Fast-path entry point for run-with-flags.js (avoids extra process spawn).
*/
function run(raw) {
try {
if (!isMacOS) return raw;
const input = raw.trim() ? JSON.parse(raw) : {};
const summary = extractSummary(input.last_assistant_message);
notifyMacOS(TITLE, summary);
} catch (err) {
log(`[DesktopNotify] Error: ${err.message}`);
}
return raw;
}
module.exports = { run };
// Legacy stdin path (when invoked directly rather than via run-with-flags)
if (require.main === module) {
const MAX_STDIN = 1024 * 1024;
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
data += chunk.substring(0, MAX_STDIN - data.length);
}
});
process.stdin.on('end', () => {
const output = run(data);
if (output) process.stdout.write(output);
});
}

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env node
/**
* Doc file warning hook (PreToolUse - Write)
* Warns about non-standard documentation files.
* Exit code 0 always (warns only, never blocks).
*/
'use strict';
const path = require('path');
const MAX_STDIN = 1024 * 1024;
let data = '';
function isAllowedDocPath(filePath) {
const normalized = filePath.replace(/\\/g, '/');
const basename = path.basename(filePath);
if (!/\.(md|txt)$/i.test(filePath)) return true;
if (/^(README|CLAUDE|AGENTS|CONTRIBUTING|CHANGELOG|LICENSE|SKILL|MEMORY|WORKLOG)\.md$/i.test(basename)) {
return true;
}
if (/\.claude\/(commands|plans|projects)\//.test(normalized)) {
return true;
}
if (/(^|\/)(docs|skills|\.history|memory)\//.test(normalized)) {
return true;
}
if (/\.plan\.md$/i.test(basename)) {
return true;
}
return false;
}
process.stdin.setEncoding('utf8');
process.stdin.on('data', c => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += c.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(data);
const filePath = String(input.tool_input?.file_path || '');
if (filePath && !isAllowedDocPath(filePath)) {
console.error('[Hook] WARNING: Non-standard documentation file detected');
console.error(`[Hook] File: ${filePath}`);
console.error('[Hook] Consider consolidating into README.md or docs/ directory');
}
} catch {
// ignore parse errors
}
process.stdout.write(data);
});

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Continuous Learning - Session Evaluator
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs on Stop hook to extract reusable patterns from Claude Code sessions.
* Reads transcript_path from stdin JSON (Claude Code hook input).
*
* Why Stop hook instead of UserPromptSubmit:
* - Stop runs once at session end (lightweight)
* - UserPromptSubmit runs every message (heavy, adds latency)
*/
const path = require('path');
const fs = require('fs');
const {
getLearnedSkillsDir,
ensureDir,
readFile,
countInFile,
log
} = require('../lib/utils');
// Read hook input from stdin (Claude Code provides transcript_path via stdin JSON)
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
const remaining = MAX_STDIN - stdinData.length;
stdinData += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
main().catch(err => {
console.error('[ContinuousLearning] Error:', err.message);
process.exit(0);
});
});
async function main() {
// Parse stdin JSON to get transcript_path
let transcriptPath = null;
try {
const input = JSON.parse(stdinData);
transcriptPath = input.transcript_path;
} catch {
// Fallback: try env var for backwards compatibility
transcriptPath = process.env.CLAUDE_TRANSCRIPT_PATH;
}
// Get script directory to find config
const scriptDir = __dirname;
const configFile = path.join(scriptDir, '..', '..', 'skills', 'continuous-learning', 'config.json');
// Default configuration
let minSessionLength = 10;
let learnedSkillsPath = getLearnedSkillsDir();
// Load config if exists
const configContent = readFile(configFile);
if (configContent) {
try {
const config = JSON.parse(configContent);
minSessionLength = config.min_session_length ?? 10;
if (config.learned_skills_path) {
// Handle ~ in path
learnedSkillsPath = config.learned_skills_path.replace(/^~/, require('os').homedir());
}
} catch (err) {
log(`[ContinuousLearning] Failed to parse config: ${err.message}, using defaults`);
}
}
// Ensure learned skills directory exists
ensureDir(learnedSkillsPath);
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
process.exit(0);
}
// Count user messages in session (allow optional whitespace around colon)
const messageCount = countInFile(transcriptPath, /"type"\s*:\s*"user"/g);
// Skip short sessions
if (messageCount < minSessionLength) {
log(`[ContinuousLearning] Session too short (${messageCount} messages), skipping`);
process.exit(0);
}
// Signal to Claude that session should be evaluated for extractable patterns
log(`[ContinuousLearning] Session has ${messageCount} messages - evaluate for extractable patterns`);
log(`[ContinuousLearning] Save learned skills to: ${learnedSkillsPath}`);
process.exit(0);
}

View File

@@ -0,0 +1,334 @@
#!/usr/bin/env node
/**
* Governance Event Capture Hook
*
* PreToolUse/PostToolUse hook that detects governance-relevant events
* and writes them to the governance_events table in the state store.
*
* Captured event types:
* - secret_detected: Hardcoded secrets in tool input/output
* - policy_violation: Actions that violate configured policies
* - security_finding: Security-relevant tool invocations
* - approval_requested: Operations requiring explicit approval
* - hook_input_truncated: Hook input exceeded the safe inspection limit
*
* Enable: Set ECC_GOVERNANCE_CAPTURE=1
* Configure session: Set ECC_SESSION_ID for session correlation
*/
'use strict';
const crypto = require('crypto');
const MAX_STDIN = 1024 * 1024;
// Patterns that indicate potential hardcoded secrets
const SECRET_PATTERNS = [
{ name: 'aws_key', pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/i },
{ name: 'generic_secret', pattern: /(?:secret|password|token|api[_-]?key)\s*[:=]\s*["'][^"']{8,}/i },
{ name: 'private_key', pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/ },
{ name: 'jwt', pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
{ name: 'github_token', pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
];
// Tool names that represent security-relevant operations
const SECURITY_RELEVANT_TOOLS = new Set([
'Bash', // Could execute arbitrary commands
]);
// Commands that require governance approval
const APPROVAL_COMMANDS = [
/git\s+push\s+.*--force/,
/git\s+reset\s+--hard/,
/rm\s+-rf?\s/,
/DROP\s+(?:TABLE|DATABASE)/i,
/DELETE\s+FROM\s+\w+\s*(?:;|$)/i,
];
// File patterns that indicate policy-sensitive paths
const SENSITIVE_PATHS = [
/\.env(?:\.|$)/,
/credentials/i,
/secrets?\./i,
/\.pem$/,
/\.key$/,
/id_rsa/,
];
/**
* Generate a unique event ID.
*/
function generateEventId() {
return `gov-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
}
/**
* Scan text content for hardcoded secrets.
* Returns array of { name, match } for each detected secret.
*/
function detectSecrets(text) {
if (!text || typeof text !== 'string') return [];
const findings = [];
for (const { name, pattern } of SECRET_PATTERNS) {
if (pattern.test(text)) {
findings.push({ name });
}
}
return findings;
}
/**
* Check if a command requires governance approval.
*/
function detectApprovalRequired(command) {
if (!command || typeof command !== 'string') return [];
const findings = [];
for (const pattern of APPROVAL_COMMANDS) {
if (pattern.test(command)) {
findings.push({ pattern: pattern.source });
}
}
return findings;
}
/**
* Check if a file path is policy-sensitive.
*/
function detectSensitivePath(filePath) {
if (!filePath || typeof filePath !== 'string') return false;
return SENSITIVE_PATHS.some(pattern => pattern.test(filePath));
}
function fingerprintCommand(command) {
if (!command || typeof command !== 'string') return null;
return crypto.createHash('sha256').update(command).digest('hex').slice(0, 12);
}
function summarizeCommand(command) {
if (!command || typeof command !== 'string') {
return {
commandName: null,
commandFingerprint: null,
};
}
const trimmed = command.trim();
if (!trimmed) {
return {
commandName: null,
commandFingerprint: null,
};
}
return {
commandName: trimmed.split(/\s+/)[0] || null,
commandFingerprint: fingerprintCommand(trimmed),
};
}
function emitGovernanceEvent(event) {
process.stderr.write(`[governance] ${JSON.stringify(event)}\n`);
}
/**
* Analyze a hook input payload and return governance events to capture.
*
* @param {Object} input - Parsed hook input (tool_name, tool_input, tool_output)
* @param {Object} [context] - Additional context (sessionId, hookPhase)
* @returns {Array<Object>} Array of governance event objects
*/
function analyzeForGovernanceEvents(input, context = {}) {
const events = [];
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
const toolOutput = typeof input.tool_output === 'string' ? input.tool_output : '';
const sessionId = context.sessionId || null;
const hookPhase = context.hookPhase || 'unknown';
// 1. Secret detection in tool input content
const inputText = typeof toolInput === 'object'
? JSON.stringify(toolInput)
: String(toolInput);
const inputSecrets = detectSecrets(inputText);
const outputSecrets = detectSecrets(toolOutput);
const allSecrets = [...inputSecrets, ...outputSecrets];
if (allSecrets.length > 0) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'secret_detected',
payload: {
toolName,
hookPhase,
secretTypes: allSecrets.map(s => s.name),
location: inputSecrets.length > 0 ? 'input' : 'output',
severity: 'critical',
},
resolvedAt: null,
resolution: null,
});
}
// 2. Approval-required commands (Bash only)
if (toolName === 'Bash') {
const command = toolInput.command || '';
const approvalFindings = detectApprovalRequired(command);
const commandSummary = summarizeCommand(command);
if (approvalFindings.length > 0) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'approval_requested',
payload: {
toolName,
hookPhase,
...commandSummary,
matchedPatterns: approvalFindings.map(f => f.pattern),
severity: 'high',
},
resolvedAt: null,
resolution: null,
});
}
}
// 3. Policy violation: writing to sensitive paths
const filePath = toolInput.file_path || toolInput.path || '';
if (filePath && detectSensitivePath(filePath)) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'policy_violation',
payload: {
toolName,
hookPhase,
filePath: filePath.slice(0, 200),
reason: 'sensitive_file_access',
severity: 'warning',
},
resolvedAt: null,
resolution: null,
});
}
// 4. Security-relevant tool usage tracking
if (SECURITY_RELEVANT_TOOLS.has(toolName) && hookPhase === 'post') {
const command = toolInput.command || '';
const hasElevated = /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
const commandSummary = summarizeCommand(command);
if (hasElevated) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'security_finding',
payload: {
toolName,
hookPhase,
...commandSummary,
reason: 'elevated_privilege_command',
severity: 'medium',
},
resolvedAt: null,
resolution: null,
});
}
}
return events;
}
/**
* Core hook logic — exported so run-with-flags.js can call directly.
*
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput, options = {}) {
// Gate on feature flag
if (String(process.env.ECC_GOVERNANCE_CAPTURE || '').toLowerCase() !== '1') {
return rawInput;
}
const sessionId = process.env.ECC_SESSION_ID || null;
const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown';
if (options.truncated) {
emitGovernanceEvent({
id: generateEventId(),
sessionId,
eventType: 'hook_input_truncated',
payload: {
hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post',
sizeLimitBytes: options.maxStdin || MAX_STDIN,
severity: 'warning',
},
resolvedAt: null,
resolution: null,
});
}
try {
const input = JSON.parse(rawInput);
const events = analyzeForGovernanceEvents(input, {
sessionId,
hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post',
});
if (events.length > 0) {
for (const event of events) {
emitGovernanceEvent(event);
}
}
} catch {
// Silently ignore parse errors — never block the tool pipeline.
}
return rawInput;
}
// ── stdin entry point ────────────────────────────────
if (require.main === module) {
let raw = '';
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
});
process.stdout.write(result);
});
}
module.exports = {
APPROVAL_COMMANDS,
SECRET_PATTERNS,
SECURITY_RELEVANT_TOOLS,
SENSITIVE_PATHS,
analyzeForGovernanceEvents,
detectApprovalRequired,
detectSecrets,
detectSensitivePath,
generateEventId,
run,
};

View File

@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""
InsAIts Security Monitor -- PreToolUse Hook for Claude Code
============================================================
Real-time security monitoring for Claude Code tool inputs.
Detects credential exposure, prompt injection, behavioral anomalies,
hallucination chains, and 20+ other anomaly types -- runs 100% locally.
Writes audit events to .insaits_audit_session.jsonl for forensic tracing.
Setup:
pip install insa-its
export ECC_ENABLE_INSAITS=1
Add to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node scripts/hooks/insaits-security-wrapper.js"
}
]
}
]
}
}
How it works:
Claude Code passes tool input as JSON on stdin.
This script runs InsAIts anomaly detection on the content.
Exit code 0 = clean (pass through).
Exit code 2 = critical issue found (blocks tool execution).
Stderr output = non-blocking warning shown to Claude.
Environment variables:
INSAITS_DEV_MODE Set to "true" to enable dev mode (no API key needed).
Defaults to "false" (strict mode).
INSAITS_MODEL LLM model identifier for fingerprinting. Default: claude-opus.
INSAITS_FAIL_MODE "open" (default) = continue on SDK errors.
"closed" = block tool execution on SDK errors.
INSAITS_VERBOSE Set to any value to enable debug logging.
Detections include:
- Credential exposure (API keys, tokens, passwords)
- Prompt injection patterns
- Hallucination indicators (phantom citations, fact contradictions)
- Behavioral anomalies (context loss, semantic drift)
- Tool description divergence
- Shorthand emergence / jargon drift
All processing is local -- no data leaves your machine.
Author: Cristi Bogdan -- YuyAI (https://github.com/Nomadu27/InsAIts)
License: Apache 2.0
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import sys
import time
from typing import Any, Dict, List, Tuple
# Configure logging to stderr so it does not interfere with stdout protocol
logging.basicConfig(
stream=sys.stderr,
format="[InsAIts] %(message)s",
level=logging.DEBUG if os.environ.get("INSAITS_VERBOSE") else logging.WARNING,
)
log = logging.getLogger("insaits-hook")
# Try importing InsAIts SDK
try:
from insa_its import insAItsMonitor
INSAITS_AVAILABLE: bool = True
except ImportError:
INSAITS_AVAILABLE = False
# --- Constants ---
AUDIT_FILE: str = ".insaits_audit_session.jsonl"
MIN_CONTENT_LENGTH: int = 10
MAX_SCAN_LENGTH: int = 4000
DEFAULT_MODEL: str = "claude-opus"
BLOCKING_SEVERITIES: frozenset = frozenset({"CRITICAL"})
def extract_content(data: Dict[str, Any]) -> Tuple[str, str]:
"""Extract inspectable text from a Claude Code tool input payload.
Returns:
A (text, context) tuple where *text* is the content to scan and
*context* is a short label for the audit log.
"""
tool_name: str = data.get("tool_name", "")
tool_input: Dict[str, Any] = data.get("tool_input", {})
text: str = ""
context: str = ""
if tool_name in ("Write", "Edit", "MultiEdit"):
text = tool_input.get("content", "") or tool_input.get("new_string", "")
context = "file:" + str(tool_input.get("file_path", ""))[:80]
elif tool_name == "Bash":
# PreToolUse: the tool hasn't executed yet, inspect the command
command: str = str(tool_input.get("command", ""))
text = command
context = "bash:" + command[:80]
elif "content" in data:
content: Any = data["content"]
if isinstance(content, list):
text = "\n".join(
b.get("text", "") for b in content if b.get("type") == "text"
)
elif isinstance(content, str):
text = content
context = str(data.get("task", ""))
return text, context
def write_audit(event: Dict[str, Any]) -> None:
"""Append an audit event to the JSONL audit log.
Creates a new dict to avoid mutating the caller's *event*.
"""
try:
enriched: Dict[str, Any] = {
**event,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
enriched["hash"] = hashlib.sha256(
json.dumps(enriched, sort_keys=True).encode()
).hexdigest()[:16]
with open(AUDIT_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(enriched) + "\n")
except OSError as exc:
log.warning("Failed to write audit log %s: %s", AUDIT_FILE, exc)
def get_anomaly_attr(anomaly: Any, key: str, default: str = "") -> str:
"""Get a field from an anomaly that may be a dict or an object.
The SDK's ``send_message()`` returns anomalies as dicts, while
other code paths may return dataclass/object instances. This
helper handles both transparently.
"""
if isinstance(anomaly, dict):
return str(anomaly.get(key, default))
return str(getattr(anomaly, key, default))
def format_feedback(anomalies: List[Any]) -> str:
"""Format detected anomalies as feedback for Claude Code.
Returns:
A human-readable multi-line string describing each finding.
"""
lines: List[str] = [
"== InsAIts Security Monitor -- Issues Detected ==",
"",
]
for i, a in enumerate(anomalies, 1):
sev: str = get_anomaly_attr(a, "severity", "MEDIUM")
atype: str = get_anomaly_attr(a, "type", "UNKNOWN")
detail: str = get_anomaly_attr(a, "details", "")
lines.extend([
f"{i}. [{sev}] {atype}",
f" {detail[:120]}",
"",
])
lines.extend([
"-" * 56,
"Fix the issues above before continuing.",
"Audit log: " + AUDIT_FILE,
])
return "\n".join(lines)
def main() -> None:
"""Entry point for the Claude Code PreToolUse hook."""
raw: str = sys.stdin.read().strip()
if not raw:
sys.exit(0)
try:
data: Dict[str, Any] = json.loads(raw)
except json.JSONDecodeError:
data = {"content": raw}
text, context = extract_content(data)
# Skip very short content (e.g. "OK", empty bash results)
if len(text.strip()) < MIN_CONTENT_LENGTH:
sys.exit(0)
if not INSAITS_AVAILABLE:
log.warning("Not installed. Run: pip install insa-its")
sys.exit(0)
# Wrap SDK calls so an internal error does not crash the hook
try:
monitor: insAItsMonitor = insAItsMonitor(
session_name="claude-code-hook",
dev_mode=os.environ.get(
"INSAITS_DEV_MODE", "false"
).lower() in ("1", "true", "yes"),
)
result: Dict[str, Any] = monitor.send_message(
text=text[:MAX_SCAN_LENGTH],
sender_id="claude-code",
llm_id=os.environ.get("INSAITS_MODEL", DEFAULT_MODEL),
)
except Exception as exc: # Broad catch intentional: unknown SDK internals
fail_mode: str = os.environ.get("INSAITS_FAIL_MODE", "open").lower()
if fail_mode == "closed":
sys.stdout.write(
f"InsAIts SDK error ({type(exc).__name__}); "
"blocking execution to avoid unscanned input.\n"
)
sys.exit(2)
log.warning(
"SDK error (%s), skipping security scan: %s",
type(exc).__name__, exc,
)
sys.exit(0)
anomalies: List[Any] = result.get("anomalies", [])
# Write audit event regardless of findings
write_audit({
"tool": data.get("tool_name", "unknown"),
"context": context,
"anomaly_count": len(anomalies),
"anomaly_types": [get_anomaly_attr(a, "type") for a in anomalies],
"text_length": len(text),
})
if not anomalies:
log.debug("Clean -- no anomalies detected.")
sys.exit(0)
# Determine maximum severity
has_critical: bool = any(
get_anomaly_attr(a, "severity").upper() in BLOCKING_SEVERITIES
for a in anomalies
)
feedback: str = format_feedback(anomalies)
if has_critical:
# stdout feedback -> Claude Code shows to the model
sys.stdout.write(feedback + "\n")
sys.exit(2) # PreToolUse exit 2 = block tool execution
else:
# Non-critical: warn via stderr (non-blocking)
log.warning("\n%s", feedback)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* InsAIts Security Monitor — wrapper for run-with-flags compatibility.
*
* This thin wrapper receives stdin from the hooks infrastructure and
* delegates to the Python-based insaits-security-monitor.py script.
*
* The wrapper exists because run-with-flags.js spawns child scripts
* via `node`, so a JS entry point is needed to bridge to Python.
*/
'use strict';
const path = require('path');
const { spawnSync } = require('child_process');
const MAX_STDIN = 1024 * 1024;
function isEnabled(value) {
return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase());
}
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
raw += chunk.substring(0, MAX_STDIN - raw.length);
}
});
process.stdin.on('end', () => {
if (!isEnabled(process.env.ECC_ENABLE_INSAITS)) {
process.stdout.write(raw);
process.exit(0);
}
const scriptDir = __dirname;
const pyScript = path.join(scriptDir, 'insaits-security-monitor.py');
// Try python3 first (macOS/Linux), fall back to python (Windows)
const pythonCandidates = ['python3', 'python'];
let result;
for (const pythonBin of pythonCandidates) {
result = spawnSync(pythonBin, [pyScript], {
input: raw,
encoding: 'utf8',
env: process.env,
cwd: process.cwd(),
timeout: 14000,
});
// ENOENT means binary not found — try next candidate
if (result.error && result.error.code === 'ENOENT') {
continue;
}
break;
}
if (!result || (result.error && result.error.code === 'ENOENT')) {
process.stderr.write('[InsAIts] python3/python not found. Install Python 3.9+ and: pip install insa-its\n');
process.stdout.write(raw);
process.exit(0);
}
// Log non-ENOENT spawn errors (timeout, signal kill, etc.) so users
// know the security monitor did not run — fail-open with a warning.
if (result.error) {
process.stderr.write(`[InsAIts] Security monitor failed to run: ${result.error.message}\n`);
process.stdout.write(raw);
process.exit(0);
}
// result.status is null when the process was killed by a signal or
// timed out. Check BEFORE writing stdout to avoid leaking partial
// or corrupt monitor output. Pass through original raw input instead.
if (!Number.isInteger(result.status)) {
const signal = result.signal || 'unknown';
process.stderr.write(`[InsAIts] Security monitor killed (signal: ${signal}). Tool execution continues.\n`);
process.stdout.write(raw);
process.exit(0);
}
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
process.exit(result.status);
});

View File

@@ -0,0 +1,619 @@
#!/usr/bin/env node
'use strict';
/**
* MCP health-check hook.
*
* Compatible with Claude Code's existing hook events:
* - PreToolUse: probe MCP server health before MCP tool execution
* - PostToolUseFailure: mark unhealthy servers, attempt reconnect, and re-probe
*
* The hook persists health state outside the conversation context so it
* survives compaction and later turns.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const https = require('https');
const { spawn, spawnSync } = require('child_process');
const MAX_STDIN = 1024 * 1024;
const DEFAULT_TTL_MS = 2 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 5000;
const DEFAULT_BACKOFF_MS = 30 * 1000;
const MAX_BACKOFF_MS = 10 * 60 * 1000;
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 405]);
const RECONNECT_STATUS_CODES = new Set([401, 403, 429, 503]);
const FAILURE_PATTERNS = [
{ code: 401, pattern: /\b401\b|unauthori[sz]ed|auth(?:entication)?\s+(?:failed|expired|invalid)/i },
{ code: 403, pattern: /\b403\b|forbidden|permission denied/i },
{ code: 429, pattern: /\b429\b|rate limit|too many requests/i },
{ code: 503, pattern: /\b503\b|service unavailable|overloaded|temporarily unavailable/i },
{ code: 'transport', pattern: /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed? out|socket hang up|connection (?:failed|lost|reset|closed)/i }
];
function envNumber(name, fallback) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function stateFilePath() {
if (process.env.ECC_MCP_HEALTH_STATE_PATH) {
return path.resolve(process.env.ECC_MCP_HEALTH_STATE_PATH);
}
return path.join(os.homedir(), '.claude', 'mcp-health-cache.json');
}
function configPaths() {
if (process.env.ECC_MCP_CONFIG_PATH) {
return process.env.ECC_MCP_CONFIG_PATH
.split(path.delimiter)
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => path.resolve(entry));
}
const cwd = process.cwd();
const home = os.homedir();
return [
path.join(cwd, '.claude.json'),
path.join(cwd, '.claude', 'settings.json'),
path.join(home, '.claude.json'),
path.join(home, '.claude', 'settings.json')
];
}
function readJsonFile(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
function loadState(filePath) {
const state = readJsonFile(filePath);
if (!state || typeof state !== 'object' || Array.isArray(state)) {
return { version: 1, servers: {} };
}
if (!state.servers || typeof state.servers !== 'object' || Array.isArray(state.servers)) {
state.servers = {};
}
return state;
}
function saveState(filePath, state) {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
} catch {
// Never block the hook on state persistence errors.
}
}
function readRawStdin() {
return new Promise(resolve => {
let raw = '';
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
});
}
function safeParse(raw) {
try {
return raw.trim() ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function extractMcpTarget(input) {
const toolName = String(input.tool_name || input.name || '');
const explicitServer = input.server
|| input.mcp_server
|| input.tool_input?.server
|| input.tool_input?.mcp_server
|| input.tool_input?.connector
|| null;
const explicitTool = input.tool
|| input.mcp_tool
|| input.tool_input?.tool
|| input.tool_input?.mcp_tool
|| null;
if (explicitServer) {
return {
server: String(explicitServer),
tool: explicitTool ? String(explicitTool) : toolName
};
}
if (!toolName.startsWith('mcp__')) {
return null;
}
const segments = toolName.slice(5).split('__');
if (segments.length < 2 || !segments[0]) {
return null;
}
return {
server: segments[0],
tool: segments.slice(1).join('__')
};
}
function extractMcpTargetFromRaw(raw) {
const toolNameMatch = raw.match(/"(?:tool_name|name)"\s*:\s*"([^"]+)"/);
const serverMatch = raw.match(/"(?:server|mcp_server|connector)"\s*:\s*"([^"]+)"/);
const toolMatch = raw.match(/"(?:tool|mcp_tool)"\s*:\s*"([^"]+)"/);
return extractMcpTarget({
tool_name: toolNameMatch ? toolNameMatch[1] : '',
server: serverMatch ? serverMatch[1] : undefined,
tool: toolMatch ? toolMatch[1] : undefined
});
}
function resolveServerConfig(serverName) {
for (const filePath of configPaths()) {
const data = readJsonFile(filePath);
const server = data?.mcpServers?.[serverName]
|| data?.mcp_servers?.[serverName]
|| null;
if (server && typeof server === 'object' && !Array.isArray(server)) {
return {
config: server,
source: filePath
};
}
}
return null;
}
function markHealthy(state, serverName, now, details = {}) {
state.servers[serverName] = {
status: 'healthy',
checkedAt: now,
expiresAt: now + envNumber('ECC_MCP_HEALTH_TTL_MS', DEFAULT_TTL_MS),
failureCount: 0,
lastError: null,
lastFailureCode: null,
nextRetryAt: now,
lastRestoredAt: now,
...details
};
}
function markUnhealthy(state, serverName, now, failureCode, errorMessage) {
const previous = state.servers[serverName] || {};
const failureCount = Number(previous.failureCount || 0) + 1;
const backoffBase = envNumber('ECC_MCP_HEALTH_BACKOFF_MS', DEFAULT_BACKOFF_MS);
const nextRetryDelay = Math.min(backoffBase * (2 ** Math.max(failureCount - 1, 0)), MAX_BACKOFF_MS);
state.servers[serverName] = {
status: 'unhealthy',
checkedAt: now,
expiresAt: now,
failureCount,
lastError: errorMessage || null,
lastFailureCode: failureCode || null,
nextRetryAt: now + nextRetryDelay,
lastRestoredAt: previous.lastRestoredAt || null
};
}
function failureSummary(input) {
const output = input.tool_output;
const pieces = [
typeof input.error === 'string' ? input.error : '',
typeof input.message === 'string' ? input.message : '',
typeof input.tool_response === 'string' ? input.tool_response : '',
typeof output === 'string' ? output : '',
typeof output?.output === 'string' ? output.output : '',
typeof output?.stderr === 'string' ? output.stderr : '',
typeof input.tool_input?.error === 'string' ? input.tool_input.error : ''
].filter(Boolean);
return pieces.join('\n');
}
function detectFailureCode(text) {
const summary = String(text || '');
for (const entry of FAILURE_PATTERNS) {
if (entry.pattern.test(summary)) {
return entry.code;
}
}
return null;
}
function requestHttp(urlString, headers, timeoutMs) {
return new Promise(resolve => {
let settled = false;
let timedOut = false;
const url = new URL(urlString);
const client = url.protocol === 'https:' ? https : http;
const req = client.request(
url,
{
method: 'GET',
headers,
},
res => {
if (settled) return;
settled = true;
res.resume();
resolve({
ok: HEALTHY_HTTP_CODES.has(res.statusCode),
statusCode: res.statusCode,
reason: `HTTP ${res.statusCode}`
});
}
);
req.setTimeout(timeoutMs, () => {
timedOut = true;
req.destroy(new Error('timeout'));
});
req.on('error', error => {
if (settled) return;
settled = true;
resolve({
ok: false,
statusCode: null,
reason: timedOut ? 'request timed out' : error.message
});
});
req.end();
});
}
function probeCommandServer(serverName, config) {
return new Promise(resolve => {
const command = config.command;
const args = Array.isArray(config.args) ? config.args.map(arg => String(arg)) : [];
const timeoutMs = envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
const mergedEnv = {
...process.env,
...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {})
};
let stderr = '';
let done = false;
function finish(result) {
if (done) return;
done = true;
resolve(result);
}
let child;
try {
child = spawn(command, args, {
env: mergedEnv,
cwd: process.cwd(),
stdio: ['pipe', 'ignore', 'pipe']
});
} catch (error) {
finish({
ok: false,
statusCode: null,
reason: error.message
});
return;
}
child.stderr.on('data', chunk => {
if (stderr.length < 4000) {
const remaining = 4000 - stderr.length;
stderr += String(chunk).slice(0, remaining);
}
});
child.on('error', error => {
finish({
ok: false,
statusCode: null,
reason: error.message
});
});
child.on('exit', (code, signal) => {
finish({
ok: false,
statusCode: code,
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
});
});
const timer = setTimeout(() => {
try {
child.kill('SIGTERM');
} catch {
// ignore
}
setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
// ignore
}
}, 200).unref?.();
finish({
ok: true,
statusCode: null,
reason: `${serverName} accepted a new stdio process`
});
}, timeoutMs);
if (typeof timer.unref === 'function') {
timer.unref();
}
});
}
async function probeServer(serverName, resolvedConfig) {
const config = resolvedConfig.config;
if (config.type === 'http' || config.url) {
const result = await requestHttp(config.url, config.headers || {}, envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS));
return {
ok: result.ok,
failureCode: RECONNECT_STATUS_CODES.has(result.statusCode) ? result.statusCode : null,
reason: result.reason,
source: resolvedConfig.source
};
}
if (config.command) {
const result = await probeCommandServer(serverName, config);
return {
ok: result.ok,
failureCode: RECONNECT_STATUS_CODES.has(result.statusCode) ? result.statusCode : null,
reason: result.reason,
source: resolvedConfig.source
};
}
return {
ok: false,
failureCode: null,
reason: 'unsupported MCP server config',
source: resolvedConfig.source
};
}
function reconnectCommand(serverName) {
const key = `ECC_MCP_RECONNECT_${String(serverName).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
const command = process.env[key] || process.env.ECC_MCP_RECONNECT_COMMAND || '';
if (!command.trim()) {
return null;
}
return command.includes('{server}')
? command.replace(/\{server\}/g, serverName)
: command;
}
function attemptReconnect(serverName) {
const command = reconnectCommand(serverName);
if (!command) {
return { attempted: false, success: false, reason: 'no reconnect command configured' };
}
const result = spawnSync(command, {
shell: true,
env: process.env,
cwd: process.cwd(),
encoding: 'utf8',
timeout: envNumber('ECC_MCP_RECONNECT_TIMEOUT_MS', DEFAULT_TIMEOUT_MS)
});
if (result.error) {
return { attempted: true, success: false, reason: result.error.message };
}
if (result.status !== 0) {
return {
attempted: true,
success: false,
reason: (result.stderr || result.stdout || `reconnect exited ${result.status}`).trim()
};
}
return { attempted: true, success: true, reason: 'reconnect command completed' };
}
function shouldFailOpen() {
return /^(1|true|yes)$/i.test(String(process.env.ECC_MCP_HEALTH_FAIL_OPEN || ''));
}
function emitLogs(logs) {
for (const line of logs) {
process.stderr.write(`${line}\n`);
}
}
async function handlePreToolUse(rawInput, input, target, statePathValue, now) {
const logs = [];
const state = loadState(statePathValue);
const previous = state.servers[target.server] || {};
if (previous.status === 'healthy' && Number(previous.expiresAt || 0) > now) {
return { rawInput, exitCode: 0, logs };
}
if (previous.status === 'unhealthy' && Number(previous.nextRetryAt || 0) > now) {
logs.push(
`[MCPHealthCheck] ${target.server} is marked unhealthy until ${new Date(previous.nextRetryAt).toISOString()}; skipping ${target.tool || 'tool'}`
);
return { rawInput, exitCode: shouldFailOpen() ? 0 : 2, logs };
}
const resolvedConfig = resolveServerConfig(target.server);
if (!resolvedConfig) {
logs.push(`[MCPHealthCheck] No MCP config found for ${target.server}; skipping preflight probe`);
return { rawInput, exitCode: 0, logs };
}
const probe = await probeServer(target.server, resolvedConfig);
if (probe.ok) {
markHealthy(state, target.server, now, { source: resolvedConfig.source });
saveState(statePathValue, state);
if (previous.status === 'unhealthy') {
logs.push(`[MCPHealthCheck] ${target.server} connection restored`);
}
return { rawInput, exitCode: 0, logs };
}
let reconnect = { attempted: false, success: false, reason: 'probe failed' };
if (probe.failureCode || previous.status === 'unhealthy') {
reconnect = attemptReconnect(target.server);
if (reconnect.success) {
const reprobe = await probeServer(target.server, resolvedConfig);
if (reprobe.ok) {
markHealthy(state, target.server, now, {
source: resolvedConfig.source,
restoredBy: 'reconnect-command'
});
saveState(statePathValue, state);
logs.push(`[MCPHealthCheck] ${target.server} connection restored after reconnect`);
return { rawInput, exitCode: 0, logs };
}
probe.reason = `${probe.reason}; reconnect reprobe failed: ${reprobe.reason}`;
}
}
markUnhealthy(state, target.server, now, probe.failureCode, probe.reason);
saveState(statePathValue, state);
const reconnectSuffix = reconnect.attempted
? ` Reconnect attempt: ${reconnect.success ? 'ok' : reconnect.reason}.`
: '';
logs.push(
`[MCPHealthCheck] ${target.server} is unavailable (${probe.reason}). Blocking ${target.tool || 'tool'} so Claude can fall back to non-MCP tools.${reconnectSuffix}`
);
return { rawInput, exitCode: shouldFailOpen() ? 0 : 2, logs };
}
async function handlePostToolUseFailure(rawInput, input, target, statePathValue, now) {
const logs = [];
const summary = failureSummary(input);
const failureCode = detectFailureCode(summary);
if (!failureCode) {
return { rawInput, exitCode: 0, logs };
}
const state = loadState(statePathValue);
markUnhealthy(state, target.server, now, failureCode, summary.slice(0, 500));
saveState(statePathValue, state);
logs.push(`[MCPHealthCheck] ${target.server} reported ${failureCode}; marking server unhealthy and attempting reconnect`);
const reconnect = attemptReconnect(target.server);
if (!reconnect.attempted) {
logs.push(`[MCPHealthCheck] ${target.server} reconnect skipped: ${reconnect.reason}`);
return { rawInput, exitCode: 0, logs };
}
if (!reconnect.success) {
logs.push(`[MCPHealthCheck] ${target.server} reconnect failed: ${reconnect.reason}`);
return { rawInput, exitCode: 0, logs };
}
const resolvedConfig = resolveServerConfig(target.server);
if (!resolvedConfig) {
logs.push(`[MCPHealthCheck] ${target.server} reconnect completed but no config was available for a follow-up probe`);
return { rawInput, exitCode: 0, logs };
}
const reprobe = await probeServer(target.server, resolvedConfig);
if (!reprobe.ok) {
logs.push(`[MCPHealthCheck] ${target.server} reconnect command ran, but health probe still failed: ${reprobe.reason}`);
return { rawInput, exitCode: 0, logs };
}
const refreshed = loadState(statePathValue);
markHealthy(refreshed, target.server, now, {
source: resolvedConfig.source,
restoredBy: 'post-failure-reconnect'
});
saveState(statePathValue, refreshed);
logs.push(`[MCPHealthCheck] ${target.server} connection restored`);
return { rawInput, exitCode: 0, logs };
}
async function main() {
const { raw: rawInput, truncated } = await readRawStdin();
const input = safeParse(rawInput);
const target = extractMcpTarget(input) || (truncated ? extractMcpTargetFromRaw(rawInput) : null);
if (!target) {
process.stdout.write(rawInput);
process.exit(0);
return;
}
if (truncated) {
const limit = Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN;
const logs = [
shouldFailOpen()
? `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; allowing ${target.tool || 'tool'} because fail-open mode is enabled`
: `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; blocking ${target.tool || 'tool'} to avoid bypassing MCP health checks`
];
emitLogs(logs);
process.stdout.write(rawInput);
process.exit(shouldFailOpen() ? 0 : 2);
return;
}
const eventName = process.env.CLAUDE_HOOK_EVENT_NAME || 'PreToolUse';
const now = Date.now();
const statePathValue = stateFilePath();
const result = eventName === 'PostToolUseFailure'
? await handlePostToolUseFailure(rawInput, input, target, statePathValue, now)
: await handlePreToolUse(rawInput, input, target, statePathValue, now);
emitLogs(result.logs);
process.stdout.write(result.rawInput);
process.exit(result.exitCode);
}
main().catch(error => {
process.stderr.write(`[MCPHealthCheck] Unexpected error: ${error.message}\n`);
process.exit(0);
});

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(raw);
const cmd = String(input.tool_input?.command || '');
if (/(npm run build|pnpm build|yarn build)/.test(cmd)) {
console.error('[Hook] Build completed - async analysis running in background');
}
} catch {
// ignore parse errors and pass through
}
process.stdout.write(raw);
});

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(raw);
const cmd = String(input.tool_input?.command || '');
if (/\bgh\s+pr\s+create\b/.test(cmd)) {
const out = String(input.tool_output?.output || '');
const match = out.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/);
if (match) {
const prUrl = match[0];
const repo = prUrl.replace(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1');
const prNum = prUrl.replace(/.+\/pull\/(\d+)/, '$1');
console.error(`[Hook] PR created: ${prUrl}`);
console.error(`[Hook] To review: gh pr review ${prNum} --repo ${repo}`);
}
}
} catch {
// ignore parse errors and pass through
}
process.stdout.write(raw);
});

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* PostToolUse Hook: Warn about console.log statements after edits
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after Edit tool use. If the edited JS/TS file contains console.log
* statements, warns with line numbers to help remove debug statements
* before committing.
*/
const { readFile } = require('../lib/utils');
const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path;
if (filePath && /\.(ts|tsx|js|jsx)$/.test(filePath)) {
const content = readFile(filePath);
if (!content) { process.stdout.write(data); process.exit(0); }
const lines = content.split('\n');
const matches = [];
lines.forEach((line, idx) => {
if (/console\.log/.test(line)) {
matches.push((idx + 1) + ': ' + line.trim());
}
});
if (matches.length > 0) {
console.error('[Hook] WARNING: console.log found in ' + filePath);
matches.slice(0, 5).forEach(m => console.error(m));
console.error('[Hook] Remove console.log before committing');
}
}
} catch {
// Invalid input — pass through
}
process.stdout.write(data);
process.exit(0);
});

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* PostToolUse Hook: Auto-format JS/TS files after edits
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after Edit tool use. If the edited file is a JS/TS file,
* auto-detects the project formatter (Biome or Prettier) by looking
* for config files, then formats accordingly.
*
* For Biome, uses `check --write` (format + lint in one pass) to
* avoid a redundant second invocation from quality-gate.js.
*
* Prefers the local node_modules/.bin binary over npx to skip
* package-resolution overhead (~200-500ms savings per invocation).
*
* Fails silently if no formatter is found or installed.
*/
const { execFileSync, spawnSync } = require('child_process');
const path = require('path');
// Shell metacharacters that cmd.exe interprets as command separators/operators
const UNSAFE_PATH_CHARS = /[&|<>^%!]/;
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
const MAX_STDIN = 1024 * 1024; // 1MB limit
/**
* Core logic — exported so run-with-flags.js can call directly
* without spawning a child process.
*
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
try {
const input = JSON.parse(rawInput);
const filePath = input.tool_input?.file_path;
if (filePath && /\.(ts|tsx|js|jsx)$/.test(filePath)) {
try {
const resolvedFilePath = path.resolve(filePath);
const projectRoot = findProjectRoot(path.dirname(resolvedFilePath));
const formatter = detectFormatter(projectRoot);
if (!formatter) return rawInput;
const resolved = resolveFormatterBin(projectRoot, formatter);
if (!resolved) return rawInput;
// Biome: `check --write` = format + lint in one pass
// Prettier: `--write` = format only
const args = formatter === 'biome' ? [...resolved.prefix, 'check', '--write', resolvedFilePath] : [...resolved.prefix, '--write', resolvedFilePath];
if (process.platform === 'win32' && resolved.bin.endsWith('.cmd')) {
// Windows: .cmd files require shell to execute. Guard against
// command injection by rejecting paths with shell metacharacters.
if (UNSAFE_PATH_CHARS.test(resolvedFilePath)) {
throw new Error('File path contains unsafe shell characters');
}
const result = spawnSync(resolved.bin, args, {
cwd: projectRoot,
shell: true,
stdio: 'pipe',
timeout: 15000
});
if (result.error) throw result.error;
if (typeof result.status === 'number' && result.status !== 0) {
throw new Error(result.stderr?.toString() || `Formatter exited with status ${result.status}`);
}
} else {
execFileSync(resolved.bin, args, {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000
});
}
} catch {
// Formatter not installed, file missing, or failed — non-blocking
}
}
} catch {
// Invalid input — pass through
}
return rawInput;
}
// ── stdin entry point (backwards-compatible) ────────────────────
if (require.main === module) {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
data = run(data);
process.stdout.write(data);
process.exit(0);
});
}
module.exports = { run };

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* PostToolUse Hook: TypeScript check after editing .ts/.tsx files
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after Edit tool use on TypeScript files. Walks up from the file's
* directory to find the nearest tsconfig.json, then runs tsc --noEmit
* and reports only errors related to the edited file.
*/
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on("end", () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path;
if (filePath && /\.(ts|tsx)$/.test(filePath)) {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
process.stdout.write(data);
process.exit(0);
}
// Find nearest tsconfig.json by walking up (max 20 levels to prevent infinite loop)
let dir = path.dirname(resolvedPath);
const root = path.parse(dir).root;
let depth = 0;
while (dir !== root && depth < 20) {
if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
break;
}
dir = path.dirname(dir);
depth++;
}
if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
try {
// Use npx.cmd on Windows to avoid shell: true which enables command injection
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
execFileSync(npxBin, ["tsc", "--noEmit", "--pretty", "false"], {
cwd: dir,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 30000,
});
} catch (err) {
// tsc exits non-zero when there are errors — filter to edited file
const output = (err.stdout || "") + (err.stderr || "");
// Compute paths that uniquely identify the edited file.
// tsc output uses paths relative to its cwd (the tsconfig dir),
// so check for the relative path, absolute path, and original path.
// Avoid bare basename matching — it causes false positives when
// multiple files share the same name (e.g., src/utils.ts vs tests/utils.ts).
const relPath = path.relative(dir, resolvedPath);
const candidates = new Set([filePath, resolvedPath, relPath]);
const relevantLines = output
.split("\n")
.filter((line) => {
for (const candidate of candidates) {
if (line.includes(candidate)) return true;
}
return false;
})
.slice(0, 10);
if (relevantLines.length > 0) {
console.error(
"[Hook] TypeScript errors in " + path.basename(filePath) + ":",
);
relevantLines.forEach((line) => console.error(line));
}
}
}
}
} catch {
// Invalid input — pass through
}
process.stdout.write(data);
process.exit(0);
});

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
const path = require('path');
const { splitShellSegments } = require('../lib/shell-split');
const DEV_COMMAND_WORDS = new Set([
'npm',
'pnpm',
'yarn',
'bun',
'npx',
'tmux'
]);
const SKIPPABLE_PREFIX_WORDS = new Set(['env', 'command', 'builtin', 'exec', 'noglob', 'sudo', 'nohup']);
const PREFIX_OPTION_VALUE_WORDS = {
env: new Set(['-u', '-C', '-S', '--unset', '--chdir', '--split-string']),
sudo: new Set([
'-u',
'-g',
'-h',
'-p',
'-r',
'-t',
'-C',
'--user',
'--group',
'--host',
'--prompt',
'--role',
'--type',
'--close-from'
])
};
function readToken(input, startIndex) {
let index = startIndex;
while (index < input.length && /\s/.test(input[index])) index += 1;
if (index >= input.length) return null;
let token = '';
let quote = null;
while (index < input.length) {
const ch = input[index];
if (quote) {
if (ch === quote) {
quote = null;
index += 1;
continue;
}
if (ch === '\\' && quote === '"' && index + 1 < input.length) {
token += input[index + 1];
index += 2;
continue;
}
token += ch;
index += 1;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
index += 1;
continue;
}
if (/\s/.test(ch)) break;
if (ch === '\\' && index + 1 < input.length) {
token += input[index + 1];
index += 2;
continue;
}
token += ch;
index += 1;
}
return { token, end: index };
}
function shouldSkipOptionValue(wrapper, optionToken) {
if (!wrapper || !optionToken || optionToken.includes('=')) return false;
const optionSet = PREFIX_OPTION_VALUE_WORDS[wrapper];
return Boolean(optionSet && optionSet.has(optionToken));
}
function isOptionToken(token) {
return token.startsWith('-') && token.length > 1;
}
function normalizeCommandWord(token) {
if (!token) return '';
const base = path.basename(token).toLowerCase();
return base.replace(/\.(cmd|exe|bat)$/i, '');
}
function getLeadingCommandWord(segment) {
let index = 0;
let activeWrapper = null;
let skipNextValue = false;
while (index < segment.length) {
const parsed = readToken(segment, index);
if (!parsed) return null;
index = parsed.end;
const token = parsed.token;
if (!token) continue;
if (skipNextValue) {
skipNextValue = false;
continue;
}
if (token === '--') {
activeWrapper = null;
continue;
}
if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token)) continue;
const normalizedToken = normalizeCommandWord(token);
if (SKIPPABLE_PREFIX_WORDS.has(normalizedToken)) {
activeWrapper = normalizedToken;
continue;
}
if (activeWrapper && isOptionToken(token)) {
if (shouldSkipOptionValue(activeWrapper, token)) {
skipNextValue = true;
}
continue;
}
return normalizedToken;
}
return null;
}
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(raw);
const cmd = String(input.tool_input?.command || '');
if (process.platform !== 'win32') {
const segments = splitShellSegments(cmd);
const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/;
const hasBlockedDev = segments.some(segment => {
const commandWord = getLeadingCommandWord(segment);
if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) {
return false;
}
return devPattern.test(segment) && !tmuxLauncher.test(segment);
});
if (hasBlockedDev) {
console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');
console.error('[Hook] Use: tmux new-session -d -s dev "npm run dev"');
console.error('[Hook] Then: tmux attach -t dev');
process.exit(2);
}
}
} catch {
// ignore parse errors and pass through
}
process.stdout.write(raw);
});

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(raw);
const cmd = String(input.tool_input?.command || '');
if (/\bgit\s+push\b/.test(cmd)) {
console.error('[Hook] Review changes before push...');
console.error('[Hook] Continuing with push (remove this hook to add interactive review)');
}
} catch {
// ignore parse errors and pass through
}
process.stdout.write(raw);
});

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(raw);
const cmd = String(input.tool_input?.command || '');
if (
process.platform !== 'win32' &&
!process.env.TMUX &&
/(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd)
) {
console.error('[Hook] Consider running in tmux for session persistence');
console.error('[Hook] tmux new -s dev | tmux attach -t dev');
}
} catch {
// ignore parse errors and pass through
}
process.stdout.write(raw);
});

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* PreCompact Hook - Save state before context compaction
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs before Claude compacts context, giving you a chance to
* preserve important state that might get lost in summarization.
*/
const path = require('path');
const {
getSessionsDir,
getDateTimeString,
getTimeString,
findFiles,
ensureDir,
appendFile,
log
} = require('../lib/utils');
async function main() {
const sessionsDir = getSessionsDir();
const compactionLog = path.join(sessionsDir, 'compaction-log.txt');
ensureDir(sessionsDir);
// Log compaction event with timestamp
const timestamp = getDateTimeString();
appendFile(compactionLog, `[${timestamp}] Context compaction triggered\n`);
// If there's an active session file, note the compaction
const sessions = findFiles(sessionsDir, '*-session.tmp');
if (sessions.length > 0) {
const activeSession = sessions[0].path;
const timeStr = getTimeString();
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
}
log('[PreCompact] State saved before compaction');
process.exit(0);
}
main().catch(err => {
console.error('[PreCompact] Error:', err.message);
process.exit(0);
});

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env node
/**
* Backward-compatible doc warning hook entrypoint.
* Kept for consumers that still reference pre-write-doc-warn.js directly.
*/
'use strict';
require('./doc-file-warning.js');

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* Quality Gate Hook
*
* Runs lightweight quality checks after file edits.
* - Targets one file when file_path is provided
* - Falls back to no-op when language/tooling is unavailable
*
* For JS/TS files with Biome, this hook is skipped because
* post-edit-format.js already runs `biome check --write`.
* This hook still handles .json/.md files for Biome, and all
* Prettier / Go / Python checks.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
const MAX_STDIN = 1024 * 1024;
/**
* Execute a command synchronously, returning the spawnSync result.
*
* @param {string} command - Executable path or name
* @param {string[]} args - Arguments to pass
* @param {string} [cwd] - Working directory (defaults to process.cwd())
* @returns {import('child_process').SpawnSyncReturns<string>}
*/
function exec(command, args, cwd = process.cwd()) {
return spawnSync(command, args, {
cwd,
encoding: 'utf8',
env: process.env,
timeout: 15000
});
}
/**
* Write a message to stderr for logging.
*
* @param {string} msg - Message to log
*/
function log(msg) {
process.stderr.write(`${msg}\n`);
}
/**
* Run quality-gate checks for a single file based on its extension.
* Skips JS/TS files when Biome is configured (handled by post-edit-format).
*
* @param {string} filePath - Path to the edited file
*/
function maybeRunQualityGate(filePath) {
if (!filePath || !fs.existsSync(filePath)) {
return;
}
// Resolve to absolute path so projectRoot-relative comparisons work
filePath = path.resolve(filePath);
const ext = path.extname(filePath).toLowerCase();
const fix = String(process.env.ECC_QUALITY_GATE_FIX || '').toLowerCase() === 'true';
const strict = String(process.env.ECC_QUALITY_GATE_STRICT || '').toLowerCase() === 'true';
if (['.ts', '.tsx', '.js', '.jsx', '.json', '.md'].includes(ext)) {
const projectRoot = findProjectRoot(path.dirname(filePath));
const formatter = detectFormatter(projectRoot);
if (formatter === 'biome') {
// JS/TS already handled by post-edit-format via `biome check --write`
if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
return;
}
// .json / .md — still need quality gate
const resolved = resolveFormatterBin(projectRoot, 'biome');
if (!resolved) return;
const args = [...resolved.prefix, 'check', filePath];
if (fix) args.push('--write');
const result = exec(resolved.bin, args, projectRoot);
if (result.status !== 0 && strict) {
log(`[QualityGate] Biome check failed for ${filePath}`);
}
return;
}
if (formatter === 'prettier') {
const resolved = resolveFormatterBin(projectRoot, 'prettier');
if (!resolved) return;
const args = [...resolved.prefix, fix ? '--write' : '--check', filePath];
const result = exec(resolved.bin, args, projectRoot);
if (result.status !== 0 && strict) {
log(`[QualityGate] Prettier check failed for ${filePath}`);
}
return;
}
// No formatter configured — skip
return;
}
if (ext === '.go') {
if (fix) {
const r = exec('gofmt', ['-w', filePath]);
if (r.status !== 0 && strict) {
log(`[QualityGate] gofmt failed for ${filePath}`);
}
} else if (strict) {
const r = exec('gofmt', ['-l', filePath]);
if (r.status !== 0) {
log(`[QualityGate] gofmt failed for ${filePath}`);
} else if (r.stdout && r.stdout.trim()) {
log(`[QualityGate] gofmt check failed for ${filePath}`);
}
}
return;
}
if (ext === '.py') {
const args = ['format'];
if (!fix) args.push('--check');
args.push(filePath);
const r = exec('ruff', args);
if (r.status !== 0 && strict) {
log(`[QualityGate] Ruff check failed for ${filePath}`);
}
}
}
/**
* Core logic — exported so run-with-flags.js can call directly.
*
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
try {
const input = JSON.parse(rawInput);
const filePath = String(input.tool_input?.file_path || '');
maybeRunQualityGate(filePath);
} catch {
// Ignore parse errors.
}
return rawInput;
}
// ── stdin entry point (backwards-compatible) ────────────────────
if (require.main === module) {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
process.stdout.write(result);
});
}
module.exports = { run };

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
HOOK_ID="${1:-}"
REL_SCRIPT_PATH="${2:-}"
PROFILES_CSV="${3:-standard,strict}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
# Preserve stdin for passthrough or script execution
INPUT="$(cat)"
if [[ -z "$HOOK_ID" || -z "$REL_SCRIPT_PATH" ]]; then
printf '%s' "$INPUT"
exit 0
fi
# Ask Node helper if this hook is enabled
ENABLED="$(node "${PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js" "$HOOK_ID" "$PROFILES_CSV" 2>/dev/null || echo yes)"
if [[ "$ENABLED" != "yes" ]]; then
printf '%s' "$INPUT"
exit 0
fi
SCRIPT_PATH="${PLUGIN_ROOT}/${REL_SCRIPT_PATH}"
if [[ ! -f "$SCRIPT_PATH" ]]; then
echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}" >&2
printf '%s' "$INPUT"
exit 0
fi
printf '%s' "$INPUT" | "$SCRIPT_PATH"

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* Executes a hook script only when enabled by ECC hook profile flags.
*
* Usage:
* node run-with-flags.js <hookId> <scriptRelativePath> [profilesCsv]
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { isHookEnabled } = require('../lib/hook-flags');
const MAX_STDIN = 1024 * 1024;
function readStdinRaw() {
return new Promise(resolve => {
let raw = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
});
}
function writeStderr(stderr) {
if (typeof stderr !== 'string' || stderr.length === 0) {
return;
}
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
}
function emitHookResult(raw, output) {
if (typeof output === 'string' || Buffer.isBuffer(output)) {
process.stdout.write(String(output));
return 0;
}
if (output && typeof output === 'object') {
writeStderr(output.stderr);
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
process.stdout.write(String(output.stdout ?? ''));
} else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) {
process.stdout.write(raw);
}
return Number.isInteger(output.exitCode) ? output.exitCode : 0;
}
process.stdout.write(raw);
return 0;
}
function writeLegacySpawnOutput(raw, result) {
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
if (stdout) {
process.stdout.write(stdout);
return;
}
if (Number.isInteger(result.status) && result.status === 0) {
process.stdout.write(raw);
}
}
function getPluginRoot() {
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
return process.env.CLAUDE_PLUGIN_ROOT;
}
return path.resolve(__dirname, '..', '..');
}
async function main() {
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
const { raw, truncated } = await readStdinRaw();
if (!hookId || !relScriptPath) {
process.stdout.write(raw);
process.exit(0);
}
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
process.stdout.write(raw);
process.exit(0);
}
const pluginRoot = getPluginRoot();
const resolvedRoot = path.resolve(pluginRoot);
const scriptPath = path.resolve(pluginRoot, relScriptPath);
// Prevent path traversal outside the plugin root
if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
process.stdout.write(raw);
process.exit(0);
}
if (!fs.existsSync(scriptPath)) {
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
process.stdout.write(raw);
process.exit(0);
}
// Prefer direct require() when the hook exports a run(rawInput) function.
// This eliminates one Node.js process spawn (~50-100ms savings per hook).
//
// SAFETY: Only require() hooks that export run(). Legacy hooks execute
// side effects at module scope (stdin listeners, process.exit, main() calls)
// which would interfere with the parent process or cause double execution.
let hookModule;
const src = fs.readFileSync(scriptPath, 'utf8');
const hasRunExport = /\bmodule\.exports\b/.test(src) && /\brun\b/.test(src);
if (hasRunExport) {
try {
hookModule = require(scriptPath);
} catch (requireErr) {
process.stderr.write(`[Hook] require() failed for ${hookId}: ${requireErr.message}\n`);
// Fall through to legacy spawnSync path
}
}
if (hookModule && typeof hookModule.run === 'function') {
try {
const output = hookModule.run(raw, { truncated, maxStdin: MAX_STDIN });
process.exit(emitHookResult(raw, output));
} catch (runErr) {
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
process.stdout.write(raw);
}
process.exit(0);
}
// Legacy path: spawn a child Node process for hooks without run() export
const result = spawnSync(process.execPath, [scriptPath], {
input: raw,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN)
},
cwd: process.cwd(),
timeout: 30000
});
writeLegacySpawnOutput(raw, result);
if (result.stderr) process.stderr.write(result.stderr);
if (result.error || result.signal || result.status === null) {
const failureDetail = result.error
? result.error.message
: result.signal
? `terminated by signal ${result.signal}`
: 'missing exit status';
writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
process.exit(1);
}
process.exit(Number.isInteger(result.status) ? result.status : 0);
}
main().catch(err => {
process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`);
process.exit(0);
});

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
'use strict';
/**
* Session end marker hook - outputs stdin to stdout unchanged.
* Exports run() for in-process execution (avoids spawnSync issues on Windows).
*/
function run(rawInput) {
return rawInput || '';
}
// Legacy CLI execution (when run directly)
if (require.main === module) {
const MAX_STDIN = 1024 * 1024;
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
process.stdout.write(raw);
});
}
module.exports = { run };

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env node
/**
* Stop Hook (Session End) - Persist learnings during active sessions
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs on Stop events (after each response). Extracts a meaningful summary
* from the session transcript (via stdin JSON transcript_path) and updates a
* session file for cross-session continuity.
*/
const path = require('path');
const fs = require('fs');
const {
getSessionsDir,
getDateString,
getTimeString,
getSessionIdShort,
getProjectName,
ensureDir,
readFile,
writeFile,
runCommand,
stripAnsi,
log
} = require('../lib/utils');
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
const SESSION_SEPARATOR = '\n---\n';
/**
* Extract a meaningful summary from the session transcript.
* Reads the JSONL transcript and pulls out key information:
* - User messages (tasks requested)
* - Tools used
* - Files modified
*/
function extractSessionSummary(transcriptPath) {
const content = readFile(transcriptPath);
if (!content) return null;
const lines = content.split('\n').filter(Boolean);
const userMessages = [];
const toolsUsed = new Set();
const filesModified = new Set();
let parseErrors = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Collect user messages (first 200 chars each)
if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') {
// Support both direct content and nested message.content (Claude Code JSONL format)
const rawContent = entry.message?.content ?? entry.content;
const text = typeof rawContent === 'string'
? rawContent
: Array.isArray(rawContent)
? rawContent.map(c => (c && c.text) || '').join(' ')
: '';
const cleaned = stripAnsi(text).trim();
if (cleaned) {
userMessages.push(cleaned.slice(0, 200));
}
}
// Collect tool names and modified files (direct tool_use entries)
if (entry.type === 'tool_use' || entry.tool_name) {
const toolName = entry.tool_name || entry.name || '';
if (toolName) toolsUsed.add(toolName);
const filePath = entry.tool_input?.file_path || entry.input?.file_path || '';
if (filePath && (toolName === 'Edit' || toolName === 'Write')) {
filesModified.add(filePath);
}
}
// Extract tool uses from assistant message content blocks (Claude Code JSONL format)
if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
for (const block of entry.message.content) {
if (block.type === 'tool_use') {
const toolName = block.name || '';
if (toolName) toolsUsed.add(toolName);
const filePath = block.input?.file_path || '';
if (filePath && (toolName === 'Edit' || toolName === 'Write')) {
filesModified.add(filePath);
}
}
}
}
} catch {
parseErrors++;
}
}
if (parseErrors > 0) {
log(`[SessionEnd] Skipped ${parseErrors}/${lines.length} unparseable transcript lines`);
}
if (userMessages.length === 0) return null;
return {
userMessages: userMessages.slice(-10), // Last 10 user messages
toolsUsed: Array.from(toolsUsed).slice(0, 20),
filesModified: Array.from(filesModified).slice(0, 30),
totalMessages: userMessages.length
};
}
// Read hook input from stdin (Claude Code provides transcript_path via stdin JSON)
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
const remaining = MAX_STDIN - stdinData.length;
stdinData += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
runMain();
});
function runMain() {
main().catch(err => {
console.error('[SessionEnd] Error:', err.message);
process.exit(0);
});
}
function getSessionMetadata() {
const branchResult = runCommand('git rev-parse --abbrev-ref HEAD');
return {
project: getProjectName() || 'unknown',
branch: branchResult.success ? branchResult.output : 'unknown',
worktree: process.cwd()
};
}
function extractHeaderField(header, label) {
const match = header.match(new RegExp(`\\*\\*${escapeRegExp(label)}:\\*\\*\\s*(.+)$`, 'm'));
return match ? match[1].trim() : null;
}
function buildSessionHeader(today, currentTime, metadata, existingContent = '') {
const headingMatch = existingContent.match(/^#\s+.+$/m);
const heading = headingMatch ? headingMatch[0] : `# Session: ${today}`;
const date = extractHeaderField(existingContent, 'Date') || today;
const started = extractHeaderField(existingContent, 'Started') || currentTime;
return [
heading,
`**Date:** ${date}`,
`**Started:** ${started}`,
`**Last Updated:** ${currentTime}`,
`**Project:** ${metadata.project}`,
`**Branch:** ${metadata.branch}`,
`**Worktree:** ${metadata.worktree}`,
''
].join('\n');
}
function mergeSessionHeader(content, today, currentTime, metadata) {
const separatorIndex = content.indexOf(SESSION_SEPARATOR);
if (separatorIndex === -1) {
return null;
}
const existingHeader = content.slice(0, separatorIndex);
const body = content.slice(separatorIndex + SESSION_SEPARATOR.length);
const nextHeader = buildSessionHeader(today, currentTime, metadata, existingHeader);
return `${nextHeader}${SESSION_SEPARATOR}${body}`;
}
async function main() {
// Parse stdin JSON to get transcript_path
let transcriptPath = null;
try {
const input = JSON.parse(stdinData);
transcriptPath = input.transcript_path;
} catch {
// Fallback: try env var for backwards compatibility
transcriptPath = process.env.CLAUDE_TRANSCRIPT_PATH;
}
const sessionsDir = getSessionsDir();
const today = getDateString();
const shortId = getSessionIdShort();
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
const sessionMetadata = getSessionMetadata();
ensureDir(sessionsDir);
const currentTime = getTimeString();
// Try to extract summary from transcript
let summary = null;
if (transcriptPath) {
if (fs.existsSync(transcriptPath)) {
summary = extractSessionSummary(transcriptPath);
} else {
log(`[SessionEnd] Transcript not found: ${transcriptPath}`);
}
}
if (fs.existsSync(sessionFile)) {
const existing = readFile(sessionFile);
let updatedContent = existing;
if (existing) {
const merged = mergeSessionHeader(existing, today, currentTime, sessionMetadata);
if (merged) {
updatedContent = merged;
} else {
log(`[SessionEnd] Failed to normalize header in ${sessionFile}`);
}
}
// If we have a new summary, update only the generated summary block.
// This keeps repeated Stop invocations idempotent and preserves
// user-authored sections in the same session file.
if (summary && updatedContent) {
const summaryBlock = buildSummaryBlock(summary);
if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) {
updatedContent = updatedContent.replace(
new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`),
summaryBlock
);
} else {
// Migration path for files created before summary markers existed.
updatedContent = updatedContent.replace(
/## (?:Session Summary|Current State)[\s\S]*?$/,
`${summaryBlock}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`
);
}
}
if (updatedContent) {
writeFile(sessionFile, updatedContent);
}
log(`[SessionEnd] Updated session file: ${sessionFile}`);
} else {
// Create new session file
const summarySection = summary
? `${buildSummaryBlock(summary)}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
: `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``;
const template = `${buildSessionHeader(today, currentTime, sessionMetadata)}${SESSION_SEPARATOR}${summarySection}
`;
writeFile(sessionFile, template);
log(`[SessionEnd] Created session file: ${sessionFile}`);
}
process.exit(0);
}
function buildSummarySection(summary) {
let section = '## Session Summary\n\n';
// Tasks (from user messages — collapse newlines and escape backticks to prevent markdown breaks)
section += '### Tasks\n';
for (const msg of summary.userMessages) {
section += `- ${msg.replace(/\n/g, ' ').replace(/`/g, '\\`')}\n`;
}
section += '\n';
// Files modified
if (summary.filesModified.length > 0) {
section += '### Files Modified\n';
for (const f of summary.filesModified) {
section += `- ${f}\n`;
}
section += '\n';
}
// Tools used
if (summary.toolsUsed.length > 0) {
section += `### Tools Used\n${summary.toolsUsed.join(', ')}\n\n`;
}
section += `### Stats\n- Total user messages: ${summary.totalMessages}\n`;
return section;
}
function buildSummaryBlock(summary) {
return `${SUMMARY_START_MARKER}\n${buildSummarySection(summary).trim()}\n${SUMMARY_END_MARKER}`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env node
/**
* SessionStart Hook - Load previous context on new session
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs when a new Claude session starts. Loads the most recent session
* summary into Claude's context via stdout, and reports available
* sessions and learned skills.
*/
const {
getSessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
findFiles,
ensureDir,
readFile,
stripAnsi,
log
} = require('../lib/utils');
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases');
const { detectProjectType } = require('../lib/project-detect');
const path = require('path');
function dedupeRecentSessions(searchDirs) {
const recentSessionsByName = new Map();
for (const [dirIndex, dir] of searchDirs.entries()) {
const matches = findFiles(dir, '*-session.tmp', { maxAge: 7 });
for (const match of matches) {
const basename = path.basename(match.path);
const current = {
...match,
basename,
dirIndex,
};
const existing = recentSessionsByName.get(basename);
if (
!existing
|| current.mtime > existing.mtime
|| (current.mtime === existing.mtime && current.dirIndex < existing.dirIndex)
) {
recentSessionsByName.set(basename, current);
}
}
}
return Array.from(recentSessionsByName.values())
.sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex);
}
async function main() {
const sessionsDir = getSessionsDir();
const learnedDir = getLearnedSkillsDir();
const additionalContextParts = [];
// Ensure directories exist
ensureDir(sessionsDir);
ensureDir(learnedDir);
// Check for recent session files (last 7 days)
const recentSessions = dedupeRecentSessions(getSessionSearchDirs());
if (recentSessions.length > 0) {
const latest = recentSessions[0];
log(`[SessionStart] Found ${recentSessions.length} recent session(s)`);
log(`[SessionStart] Latest: ${latest.path}`);
// Read and inject the latest session content into Claude's context
const content = stripAnsi(readFile(latest.path));
if (content && !content.includes('[Session context goes here]')) {
// Only inject if the session has actual content (not the blank template)
additionalContextParts.push(`Previous session summary:\n${content}`);
}
}
// Check for learned skills
const learnedSkills = findFiles(learnedDir, '*.md');
if (learnedSkills.length > 0) {
log(`[SessionStart] ${learnedSkills.length} learned skill(s) available in ${learnedDir}`);
}
// Check for available session aliases
const aliases = listAliases({ limit: 5 });
if (aliases.length > 0) {
const aliasNames = aliases.map(a => a.name).join(', ');
log(`[SessionStart] ${aliases.length} session alias(es) available: ${aliasNames}`);
log(`[SessionStart] Use /sessions load <alias> to continue a previous session`);
}
// Detect and report package manager
const pm = getPackageManager();
log(`[SessionStart] Package manager: ${pm.name} (${pm.source})`);
// If no explicit package manager config was found, show selection prompt
if (pm.source === 'default') {
log('[SessionStart] No package manager preference found.');
log(getSelectionPrompt());
}
// Detect project type and frameworks (#293)
const projectInfo = detectProjectType();
if (projectInfo.languages.length > 0 || projectInfo.frameworks.length > 0) {
const parts = [];
if (projectInfo.languages.length > 0) {
parts.push(`languages: ${projectInfo.languages.join(', ')}`);
}
if (projectInfo.frameworks.length > 0) {
parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`);
}
log(`[SessionStart] Project detected — ${parts.join('; ')}`);
additionalContextParts.push(`Project type: ${JSON.stringify(projectInfo)}`);
} else {
log('[SessionStart] No specific project type detected');
}
await writeSessionStartPayload(additionalContextParts.join('\n\n'));
}
function writeSessionStartPayload(additionalContext) {
return new Promise((resolve, reject) => {
let settled = false;
const payload = JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext
}
});
const handleError = (err) => {
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
}
reject(err || new Error('stdout stream error'));
};
process.stdout.once('error', handleError);
process.stdout.write(payload, (err) => {
process.stdout.removeListener('error', handleError);
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
reject(err);
return;
}
resolve();
});
});
}
main().catch(err => {
console.error('[SessionStart] Error:', err.message);
process.exitCode = 0; // Don't block on errors
});

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Strategic Compact Suggester
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs on PreToolUse or periodically to suggest manual compaction at logical intervals
*
* Why manual over auto-compact:
* - Auto-compact happens at arbitrary points, often mid-task
* - Strategic compacting preserves context through logical phases
* - Compact after exploration, before execution
* - Compact after completing a milestone, before starting next
*/
const fs = require('fs');
const path = require('path');
const {
getTempDir,
writeFile,
log
} = require('../lib/utils');
async function main() {
// Track tool call count (increment in a temp file)
// Use a session-specific counter file based on session ID from environment
// or parent PID as fallback
const sessionId = (process.env.CLAUDE_SESSION_ID || 'default').replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
const counterFile = path.join(getTempDir(), `claude-tool-count-${sessionId}`);
const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10);
const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000
? rawThreshold
: 50;
let count = 1;
// Read existing count or start at 1
// Use fd-based read+write to reduce (but not eliminate) race window
// between concurrent hook invocations
try {
const fd = fs.openSync(counterFile, 'a+');
try {
const buf = Buffer.alloc(64);
const bytesRead = fs.readSync(fd, buf, 0, 64, 0);
if (bytesRead > 0) {
const parsed = parseInt(buf.toString('utf8', 0, bytesRead).trim(), 10);
// Clamp to reasonable range — corrupted files could contain huge values
// that pass Number.isFinite() (e.g., parseInt('9'.repeat(30)) => 1e+29)
count = (Number.isFinite(parsed) && parsed > 0 && parsed <= 1000000)
? parsed + 1
: 1;
}
// Truncate and write new value
fs.ftruncateSync(fd, 0);
fs.writeSync(fd, String(count), 0);
} finally {
fs.closeSync(fd);
}
} catch {
// Fallback: just use writeFile if fd operations fail
writeFile(counterFile, String(count));
}
// Suggest compact after threshold tool calls
if (count === threshold) {
log(`[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`);
}
// Suggest at regular intervals after threshold (every 25 calls from threshold)
if (count > threshold && (count - threshold) % 25 === 0) {
log(`[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`);
}
process.exit(0);
}
main().catch(err => {
console.error('[StrategicCompact] Error:', err.message);
process.exit(0);
});

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
/**
* Refactored ECC installer runtime.
*
* Keeps the legacy language-based install entrypoint intact while moving
* target-specific mutation logic into testable Node code.
*/
const {
SUPPORTED_INSTALL_TARGETS,
listLegacyCompatibilityLanguages,
} = require('./lib/install-manifests');
const {
LEGACY_INSTALL_TARGETS,
normalizeInstallRequest,
parseInstallArgs,
} = require('./lib/install/request');
function showHelp(exitCode = 0) {
const languages = listLegacyCompatibilityLanguages();
console.log(`
Usage: install.sh [--target <${LEGACY_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] <language> [<language> ...]
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --profile <name> [--with <component>]... [--without <component>]...
install.sh [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] --modules <id,id,...> [--with <component>]... [--without <component>]...
install.sh [--dry-run] [--json] --config <path>
Targets:
claude (default) - Install rules to ~/.claude/rules/
cursor - Install rules, hooks, and bundled Cursor configs to ./.cursor/
antigravity - Install rules, workflows, skills, and agents to ./.agent/
Options:
--profile <name> Resolve and install a manifest profile
--modules <ids> Resolve and install explicit module IDs
--with <component> Include a user-facing install component
--without <component>
Exclude a user-facing install component
--config <path> Load install intent from ecc-install.json
--dry-run Show the install plan without copying files
--json Emit machine-readable plan/result JSON
--help Show this help text
Available languages:
${languages.map(language => ` - ${language}`).join('\n')}
`);
process.exit(exitCode);
}
function printHumanPlan(plan, dryRun) {
console.log(`${dryRun ? 'Dry-run install plan' : 'Applying install plan'}:\n`);
console.log(`Mode: ${plan.mode}`);
console.log(`Target: ${plan.target}`);
console.log(`Adapter: ${plan.adapter.id}`);
console.log(`Install root: ${plan.installRoot}`);
console.log(`Install-state: ${plan.installStatePath}`);
if (plan.mode === 'legacy') {
console.log(`Languages: ${plan.languages.join(', ')}`);
} else {
if (plan.mode === 'legacy-compat') {
console.log(`Legacy languages: ${plan.legacyLanguages.join(', ')}`);
}
console.log(`Profile: ${plan.profileId || '(custom modules)'}`);
console.log(`Included components: ${plan.includedComponentIds.join(', ') || '(none)'}`);
console.log(`Excluded components: ${plan.excludedComponentIds.join(', ') || '(none)'}`);
console.log(`Requested modules: ${plan.requestedModuleIds.join(', ') || '(none)'}`);
console.log(`Selected modules: ${plan.selectedModuleIds.join(', ') || '(none)'}`);
if (plan.skippedModuleIds.length > 0) {
console.log(`Skipped modules: ${plan.skippedModuleIds.join(', ')}`);
}
if (plan.excludedModuleIds.length > 0) {
console.log(`Excluded modules: ${plan.excludedModuleIds.join(', ')}`);
}
}
console.log(`Operations: ${plan.operations.length}`);
if (plan.warnings.length > 0) {
console.log('\nWarnings:');
for (const warning of plan.warnings) {
console.log(`- ${warning}`);
}
}
console.log('\nPlanned file operations:');
for (const operation of plan.operations) {
console.log(`- ${operation.sourceRelativePath} -> ${operation.destinationPath}`);
}
if (!dryRun) {
console.log(`\nDone. Install-state written to ${plan.installStatePath}`);
}
}
function main() {
try {
const options = parseInstallArgs(process.argv);
if (options.help) {
showHelp(0);
}
const {
findDefaultInstallConfigPath,
loadInstallConfig,
} = require('./lib/install/config');
const { applyInstallPlan } = require('./lib/install-executor');
const { createInstallPlanFromRequest } = require('./lib/install/runtime');
const defaultConfigPath = options.configPath || options.languages.length > 0
? null
: findDefaultInstallConfigPath({ cwd: process.cwd() });
const config = options.configPath
? loadInstallConfig(options.configPath, { cwd: process.cwd() })
: (defaultConfigPath ? loadInstallConfig(defaultConfigPath, { cwd: process.cwd() }) : null);
const request = normalizeInstallRequest({
...options,
config,
});
const plan = createInstallPlanFromRequest(request, {
projectRoot: process.cwd(),
homeDir: process.env.HOME,
claudeRulesDir: process.env.CLAUDE_RULES_DIR || null,
});
if (options.dryRun) {
if (options.json) {
console.log(JSON.stringify({ dryRun: true, plan }, null, 2));
} else {
printHumanPlan(plan, true);
}
return;
}
const result = applyInstallPlan(plan);
if (options.json) {
console.log(JSON.stringify({ dryRun: false, result }, null, 2));
} else {
printHumanPlan(result, false);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env node
/**
* Inspect selective-install profiles and module plans without mutating targets.
*/
const {
listInstallComponents,
listInstallModules,
listInstallProfiles,
resolveInstallPlan,
} = require('./lib/install-manifests');
const {
findDefaultInstallConfigPath,
loadInstallConfig,
} = require('./lib/install/config');
const { normalizeInstallRequest } = require('./lib/install/request');
function showHelp() {
console.log(`
Inspect ECC selective-install manifests
Usage:
node scripts/install-plan.js --list-profiles
node scripts/install-plan.js --list-modules
node scripts/install-plan.js --list-components [--family <family>] [--target <target>] [--json]
node scripts/install-plan.js --profile <name> [--with <component>]... [--without <component>]... [--target <target>] [--json]
node scripts/install-plan.js --modules <id,id,...> [--with <component>]... [--without <component>]... [--target <target>] [--json]
node scripts/install-plan.js --config <path> [--json]
Options:
--list-profiles List available install profiles
--list-modules List install modules
--list-components List user-facing install components
--family <family> Filter listed components by family
--profile <name> Resolve an install profile
--modules <ids> Resolve explicit module IDs (comma-separated)
--with <component> Include a user-facing install component
--without <component>
Exclude a user-facing install component
--config <path> Load install intent from ecc-install.json
--target <target> Filter plan for a specific target
--json Emit machine-readable JSON
--help Show this help text
`);
}
function parseArgs(argv) {
const args = argv.slice(2);
const parsed = {
json: false,
help: false,
profileId: null,
moduleIds: [],
includeComponentIds: [],
excludeComponentIds: [],
configPath: null,
target: null,
family: null,
listProfiles: false,
listModules: false,
listComponents: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else if (arg === '--json') {
parsed.json = true;
} else if (arg === '--list-profiles') {
parsed.listProfiles = true;
} else if (arg === '--list-modules') {
parsed.listModules = true;
} else if (arg === '--list-components') {
parsed.listComponents = true;
} else if (arg === '--family') {
parsed.family = args[index + 1] || null;
index += 1;
} else if (arg === '--profile') {
parsed.profileId = args[index + 1] || null;
index += 1;
} else if (arg === '--modules') {
const raw = args[index + 1] || '';
parsed.moduleIds = raw.split(',').map(value => value.trim()).filter(Boolean);
index += 1;
} else if (arg === '--with') {
const componentId = args[index + 1] || '';
if (componentId.trim()) {
parsed.includeComponentIds.push(componentId.trim());
}
index += 1;
} else if (arg === '--without') {
const componentId = args[index + 1] || '';
if (componentId.trim()) {
parsed.excludeComponentIds.push(componentId.trim());
}
index += 1;
} else if (arg === '--config') {
parsed.configPath = args[index + 1] || null;
index += 1;
} else if (arg === '--target') {
parsed.target = args[index + 1] || null;
index += 1;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return parsed;
}
function printProfiles(profiles) {
console.log('Install profiles:\n');
for (const profile of profiles) {
console.log(`- ${profile.id} (${profile.moduleCount} modules)`);
console.log(` ${profile.description}`);
}
}
function printModules(modules) {
console.log('Install modules:\n');
for (const module of modules) {
console.log(`- ${module.id} [${module.kind}]`);
console.log(
` targets=${module.targets.join(', ')} default=${module.defaultInstall} cost=${module.cost} stability=${module.stability}`
);
console.log(` ${module.description}`);
}
}
function printComponents(components) {
console.log('Install components:\n');
for (const component of components) {
console.log(`- ${component.id} [${component.family}]`);
console.log(` targets=${component.targets.join(', ')} modules=${component.moduleIds.join(', ')}`);
console.log(` ${component.description}`);
}
}
function printPlan(plan) {
console.log('Install plan:\n');
console.log(
'Note: target filtering and operation output currently reflect scaffold-level adapter planning, not a byte-for-byte mirror of legacy install.sh copy paths.\n'
);
console.log(`Profile: ${plan.profileId || '(custom modules)'}`);
console.log(`Target: ${plan.target || '(all targets)'}`);
console.log(`Included components: ${plan.includedComponentIds.join(', ') || '(none)'}`);
console.log(`Excluded components: ${plan.excludedComponentIds.join(', ') || '(none)'}`);
console.log(`Requested: ${plan.requestedModuleIds.join(', ')}`);
if (plan.targetAdapterId) {
console.log(`Adapter: ${plan.targetAdapterId}`);
console.log(`Target root: ${plan.targetRoot}`);
console.log(`Install-state: ${plan.installStatePath}`);
}
console.log('');
console.log(`Selected modules (${plan.selectedModuleIds.length}):`);
for (const module of plan.selectedModules) {
console.log(`- ${module.id} [${module.kind}]`);
}
if (plan.skippedModuleIds.length > 0) {
console.log('');
console.log(`Skipped for target ${plan.target} (${plan.skippedModuleIds.length}):`);
for (const module of plan.skippedModules) {
console.log(`- ${module.id} [${module.kind}]`);
}
}
if (plan.excludedModuleIds.length > 0) {
console.log('');
console.log(`Excluded by selection (${plan.excludedModuleIds.length}):`);
for (const module of plan.excludedModules) {
console.log(`- ${module.id} [${module.kind}]`);
}
}
if (plan.operations.length > 0) {
console.log('');
console.log(`Operation plan (${plan.operations.length}):`);
for (const operation of plan.operations) {
console.log(
`- ${operation.moduleId}: ${operation.sourceRelativePath} -> ${operation.destinationPath} [${operation.strategy}]`
);
}
}
}
function main() {
try {
const options = parseArgs(process.argv);
if (options.help) {
showHelp();
process.exit(0);
}
if (options.listProfiles) {
const profiles = listInstallProfiles();
if (options.json) {
console.log(JSON.stringify({ profiles }, null, 2));
} else {
printProfiles(profiles);
}
return;
}
if (options.listModules) {
const modules = listInstallModules();
if (options.json) {
console.log(JSON.stringify({ modules }, null, 2));
} else {
printModules(modules);
}
return;
}
if (options.listComponents) {
const components = listInstallComponents({
family: options.family,
target: options.target,
});
if (options.json) {
console.log(JSON.stringify({ components }, null, 2));
} else {
printComponents(components);
}
return;
}
const defaultConfigPath = options.configPath
? null
: findDefaultInstallConfigPath({ cwd: process.cwd() });
const config = options.configPath
? loadInstallConfig(options.configPath, { cwd: process.cwd() })
: (defaultConfigPath ? loadInstallConfig(defaultConfigPath, { cwd: process.cwd() }) : null);
if (process.argv.length <= 2 && !config) {
showHelp();
process.exit(0);
}
const request = normalizeInstallRequest({
...options,
languages: [],
config,
});
const plan = resolveInstallPlan({
profileId: request.profileId,
moduleIds: request.moduleIds,
includeComponentIds: request.includeComponentIds,
excludeComponentIds: request.excludeComponentIds,
target: request.target,
});
if (options.json) {
console.log(JSON.stringify(plan, null, 2));
} else {
printPlan(plan);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,244 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* Parse YAML frontmatter from a markdown string.
* Returns { frontmatter: {}, body: string }.
*/
function parseFrontmatter(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
if (!match) {
return { frontmatter: {}, body: content };
}
const frontmatter = {};
for (const line of match[1].split('\n')) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
// Handle JSON arrays (e.g. tools: ["Read", "Grep"])
if (value.startsWith('[') && value.endsWith(']')) {
try {
value = JSON.parse(value);
} catch {
// keep as string
}
}
// Strip surrounding quotes
if (typeof value === 'string' && value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
frontmatter[key] = value;
}
return { frontmatter, body: match[2] || '' };
}
/**
* Extract the first meaningful paragraph from agent body as a summary.
* Skips headings, list items, code blocks, and table rows.
*/
function extractSummary(body, maxSentences = 1) {
const lines = body.split('\n');
const paragraphs = [];
let current = [];
let inCodeBlock = false;
for (const line of lines) {
const trimmed = line.trim();
// Track fenced code blocks
if (trimmed.startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) continue;
if (trimmed === '') {
if (current.length > 0) {
paragraphs.push(current.join(' '));
current = [];
}
continue;
}
// Skip headings, list items (bold, plain, asterisk), numbered lists, table rows
if (
trimmed.startsWith('#') ||
trimmed.startsWith('- ') ||
trimmed.startsWith('* ') ||
/^\d+\.\s/.test(trimmed) ||
trimmed.startsWith('|')
) {
if (current.length > 0) {
paragraphs.push(current.join(' '));
current = [];
}
continue;
}
current.push(trimmed);
}
if (current.length > 0) {
paragraphs.push(current.join(' '));
}
const firstParagraph = paragraphs.find(p => p.length > 0);
if (!firstParagraph) return '';
const sentences = firstParagraph.match(/[^.!?]+[.!?]+/g) || [firstParagraph];
return sentences.slice(0, maxSentences).map(s => s.trim()).join(' ').trim();
}
/**
* Load and parse a single agent file.
*/
function loadAgent(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const { frontmatter, body } = parseFrontmatter(content);
const fileName = path.basename(filePath, '.md');
return {
fileName,
name: frontmatter.name || fileName,
description: frontmatter.description || '',
tools: Array.isArray(frontmatter.tools) ? frontmatter.tools : [],
model: frontmatter.model || 'sonnet',
body,
byteSize: Buffer.byteLength(content, 'utf8'),
};
}
/**
* Load all agents from a directory.
*/
function loadAgents(agentsDir) {
if (!fs.existsSync(agentsDir)) return [];
return fs.readdirSync(agentsDir)
.filter(f => f.endsWith('.md'))
.sort()
.map(f => loadAgent(path.join(agentsDir, f)));
}
/**
* Compress an agent to catalog entry (metadata only).
*/
function compressToCatalog(agent) {
return {
name: agent.name,
description: agent.description,
tools: agent.tools,
model: agent.model,
};
}
/**
* Compress an agent to summary entry (metadata + first paragraph).
*/
function compressToSummary(agent) {
return {
...compressToCatalog(agent),
summary: extractSummary(agent.body),
};
}
const allowedModes = ['catalog', 'summary', 'full'];
/**
* Build a compressed catalog from a directory of agents.
*
* Modes:
* - 'catalog': name, description, tools, model only (~2-3k tokens for 27 agents)
* - 'summary': catalog + first paragraph summary (~4-5k tokens)
* - 'full': no compression, full body included
*
* Returns { agents: [], stats: { totalAgents, originalBytes, compressedBytes, compressedTokenEstimate, mode } }
*/
function buildAgentCatalog(agentsDir, options = {}) {
const mode = options.mode || 'catalog';
if (!allowedModes.includes(mode)) {
throw new Error(`Invalid mode "${mode}". Allowed modes: ${allowedModes.join(', ')}`);
}
const filter = options.filter || null;
let agents = loadAgents(agentsDir);
if (typeof filter === 'function') {
agents = agents.filter(filter);
}
const originalBytes = agents.reduce((sum, a) => sum + a.byteSize, 0);
let compressed;
if (mode === 'catalog') {
compressed = agents.map(compressToCatalog);
} else if (mode === 'summary') {
compressed = agents.map(compressToSummary);
} else {
compressed = agents.map(a => ({
name: a.name,
description: a.description,
tools: a.tools,
model: a.model,
body: a.body,
}));
}
const compressedJson = JSON.stringify(compressed);
// Rough token estimate: ~4 chars per token for English text
const compressedTokenEstimate = Math.ceil(compressedJson.length / 4);
return {
agents: compressed,
stats: {
totalAgents: agents.length,
originalBytes,
compressedBytes: Buffer.byteLength(compressedJson, 'utf8'),
compressedTokenEstimate,
mode,
},
};
}
/**
* Lazy-load a single agent's full content by name.
* Returns null if not found.
*/
function lazyLoadAgent(agentsDir, agentName) {
// Validate agentName: only allow alphanumeric, hyphen, underscore
if (!/^[\w-]+$/.test(agentName)) {
return null;
}
const filePath = path.resolve(agentsDir, `${agentName}.md`);
// Verify the resolved path is still within agentsDir
const resolvedAgentsDir = path.resolve(agentsDir);
if (!filePath.startsWith(resolvedAgentsDir + path.sep)) {
return null;
}
if (!fs.existsSync(filePath)) return null;
return loadAgent(filePath);
}
module.exports = {
buildAgentCatalog,
compressToCatalog,
compressToSummary,
extractSummary,
lazyLoadAgent,
loadAgent,
loadAgents,
parseFrontmatter,
};

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* Shared hook enable/disable controls.
*
* Controls:
* - ECC_HOOK_PROFILE=minimal|standard|strict (default: standard)
* - ECC_DISABLED_HOOKS=comma,separated,hook,ids
*/
'use strict';
const VALID_PROFILES = new Set(['minimal', 'standard', 'strict']);
function normalizeId(value) {
return String(value || '').trim().toLowerCase();
}
function getHookProfile() {
const raw = String(process.env.ECC_HOOK_PROFILE || 'standard').trim().toLowerCase();
return VALID_PROFILES.has(raw) ? raw : 'standard';
}
function getDisabledHookIds() {
const raw = String(process.env.ECC_DISABLED_HOOKS || '');
if (!raw.trim()) return new Set();
return new Set(
raw
.split(',')
.map(v => normalizeId(v))
.filter(Boolean)
);
}
function parseProfiles(rawProfiles, fallback = ['standard', 'strict']) {
if (!rawProfiles) return [...fallback];
if (Array.isArray(rawProfiles)) {
const parsed = rawProfiles
.map(v => String(v || '').trim().toLowerCase())
.filter(v => VALID_PROFILES.has(v));
return parsed.length > 0 ? parsed : [...fallback];
}
const parsed = String(rawProfiles)
.split(',')
.map(v => v.trim().toLowerCase())
.filter(v => VALID_PROFILES.has(v));
return parsed.length > 0 ? parsed : [...fallback];
}
function isHookEnabled(hookId, options = {}) {
const id = normalizeId(hookId);
if (!id) return true;
const disabled = getDisabledHookIds();
if (disabled.has(id)) {
return false;
}
const profile = getHookProfile();
const allowedProfiles = parseProfiles(options.profiles);
return allowedProfiles.includes(profile);
}
module.exports = {
VALID_PROFILES,
normalizeId,
getHookProfile,
getDisabledHookIds,
parseProfiles,
isHookEnabled,
};

View File

@@ -0,0 +1,212 @@
'use strict';
const DEFAULT_FAILURE_THRESHOLD = 3;
const DEFAULT_WINDOW_SIZE = 50;
const FAILURE_OUTCOMES = new Set(['failure', 'failed', 'error']);
/**
* Normalize a failure reason string for grouping.
* Strips timestamps, UUIDs, file paths, and numeric suffixes.
*/
function normalizeFailureReason(reason) {
if (!reason || typeof reason !== 'string') {
return 'unknown';
}
return reason
.trim()
.toLowerCase()
// Strip ISO timestamps (note: already lowercased, so t/z not T/Z)
.replace(/\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2}:\d{2}[.\dz]*/g, '<timestamp>')
// Strip UUIDs (already lowercased)
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '<uuid>')
// Strip file paths
.replace(/\/[\w./-]+/g, '<path>')
// Collapse whitespace
.replace(/\s+/g, ' ')
.trim();
}
/**
* Group skill runs by skill ID and normalized failure reason.
*
* @param {Array} skillRuns - Array of skill run objects
* @returns {Map<string, { skillId: string, normalizedReason: string, runs: Array }>}
*/
function groupFailures(skillRuns) {
const groups = new Map();
for (const run of skillRuns) {
const outcome = String(run.outcome || '').toLowerCase();
if (!FAILURE_OUTCOMES.has(outcome)) {
continue;
}
const normalizedReason = normalizeFailureReason(run.failureReason);
const key = `${run.skillId}::${normalizedReason}`;
if (!groups.has(key)) {
groups.set(key, {
skillId: run.skillId,
normalizedReason,
runs: [],
});
}
groups.get(key).runs.push(run);
}
return groups;
}
/**
* Detect recurring failure patterns from skill runs.
*
* @param {Array} skillRuns - Array of skill run objects (newest first)
* @param {Object} [options]
* @param {number} [options.threshold=3] - Minimum failure count to trigger pattern detection
* @returns {Array<Object>} Array of detected patterns sorted by count descending
*/
function detectPatterns(skillRuns, options = {}) {
const threshold = options.threshold ?? DEFAULT_FAILURE_THRESHOLD;
const groups = groupFailures(skillRuns);
const patterns = [];
for (const [, group] of groups) {
if (group.runs.length < threshold) {
continue;
}
const sortedRuns = [...group.runs].sort(
(a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')
);
const firstSeen = sortedRuns[sortedRuns.length - 1].createdAt || null;
const lastSeen = sortedRuns[0].createdAt || null;
const sessionIds = [...new Set(sortedRuns.map(r => r.sessionId).filter(Boolean))];
const versions = [...new Set(sortedRuns.map(r => r.skillVersion).filter(Boolean))];
// Collect unique raw failure reasons for this normalized group
const rawReasons = [...new Set(sortedRuns.map(r => r.failureReason).filter(Boolean))];
patterns.push({
skillId: group.skillId,
normalizedReason: group.normalizedReason,
count: group.runs.length,
firstSeen,
lastSeen,
sessionIds,
versions,
rawReasons,
runIds: sortedRuns.map(r => r.id),
});
}
// Sort by count descending, then by lastSeen descending
return patterns.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return (b.lastSeen || '').localeCompare(a.lastSeen || '');
});
}
/**
* Generate an inspection report from detected patterns.
*
* @param {Array} patterns - Output from detectPatterns()
* @param {Object} [options]
* @param {string} [options.generatedAt] - ISO timestamp for the report
* @returns {Object} Inspection report
*/
function generateReport(patterns, options = {}) {
const generatedAt = options.generatedAt || new Date().toISOString();
if (patterns.length === 0) {
return {
generatedAt,
status: 'clean',
patternCount: 0,
patterns: [],
summary: 'No recurring failure patterns detected.',
};
}
const totalFailures = patterns.reduce((sum, p) => sum + p.count, 0);
const affectedSkills = [...new Set(patterns.map(p => p.skillId))];
return {
generatedAt,
status: 'attention_needed',
patternCount: patterns.length,
totalFailures,
affectedSkills,
patterns: patterns.map(p => ({
skillId: p.skillId,
normalizedReason: p.normalizedReason,
count: p.count,
firstSeen: p.firstSeen,
lastSeen: p.lastSeen,
sessionIds: p.sessionIds,
versions: p.versions,
rawReasons: p.rawReasons.slice(0, 5),
suggestedAction: suggestAction(p),
})),
summary: `Found ${patterns.length} recurring failure pattern(s) across ${affectedSkills.length} skill(s) (${totalFailures} total failures).`,
};
}
/**
* Suggest a remediation action based on pattern characteristics.
*/
function suggestAction(pattern) {
const reason = pattern.normalizedReason;
if (reason.includes('timeout')) {
return 'Increase timeout or optimize skill execution time.';
}
if (reason.includes('permission') || reason.includes('denied') || reason.includes('auth')) {
return 'Check tool permissions and authentication configuration.';
}
if (reason.includes('not found') || reason.includes('missing')) {
return 'Verify required files/dependencies exist before skill execution.';
}
if (reason.includes('parse') || reason.includes('syntax') || reason.includes('json')) {
return 'Review input/output format expectations and add validation.';
}
if (pattern.versions.length > 1) {
return 'Failure spans multiple versions. Consider rollback to last stable version.';
}
return 'Investigate root cause and consider adding error handling.';
}
/**
* Run full inspection pipeline: query skill runs, detect patterns, generate report.
*
* @param {Object} store - State store instance with listRecentSessions, getSessionDetail
* @param {Object} [options]
* @param {number} [options.threshold] - Minimum failure count
* @param {number} [options.windowSize] - Number of recent skill runs to analyze
* @returns {Object} Inspection report
*/
function inspect(store, options = {}) {
const windowSize = options.windowSize ?? DEFAULT_WINDOW_SIZE;
const threshold = options.threshold ?? DEFAULT_FAILURE_THRESHOLD;
const status = store.getStatus({ recentSkillRunLimit: windowSize });
const skillRuns = status.skillRuns.recent || [];
const patterns = detectPatterns(skillRuns, { threshold });
return generateReport(patterns, { generatedAt: status.generatedAt });
}
module.exports = {
DEFAULT_FAILURE_THRESHOLD,
DEFAULT_WINDOW_SIZE,
detectPatterns,
generateReport,
groupFailures,
inspect,
normalizeFailureReason,
suggestAction,
};

View File

@@ -0,0 +1,671 @@
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
const {
SUPPORTED_INSTALL_TARGETS,
listLegacyCompatibilityLanguages,
resolveLegacyCompatibilitySelection,
resolveInstallPlan,
} = require('./install-manifests');
const { getInstallTargetAdapter } = require('./install-targets/registry');
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = [
'/ecc-install-state.json',
'/ecc/install-state.json',
];
function getSourceRoot() {
return path.join(__dirname, '../..');
}
function getPackageVersion(sourceRoot) {
try {
const packageJson = JSON.parse(
fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8')
);
return packageJson.version || null;
} catch (_error) {
return null;
}
}
function getManifestVersion(sourceRoot) {
try {
const modulesManifest = JSON.parse(
fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8')
);
return modulesManifest.version || 1;
} catch (_error) {
return 1;
}
}
function getRepoCommit(sourceRoot) {
try {
return execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: sourceRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
}).trim();
} catch (_error) {
return null;
}
}
function readDirectoryNames(dirPath) {
if (!fs.existsSync(dirPath)) {
return [];
}
return fs.readdirSync(dirPath, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
}
function listAvailableLanguages(sourceRoot = getSourceRoot()) {
return [...new Set([
...listLegacyCompatibilityLanguages(),
...readDirectoryNames(path.join(sourceRoot, 'rules'))
.filter(name => name !== 'common'),
])].sort();
}
function validateLegacyTarget(target) {
if (!LEGACY_INSTALL_TARGETS.includes(target)) {
throw new Error(
`Unknown install target: ${target}. Expected one of ${LEGACY_INSTALL_TARGETS.join(', ')}`
);
}
}
const IGNORED_DIRECTORY_NAMES = new Set([
'node_modules',
'.git',
]);
function listFilesRecursive(dirPath) {
if (!fs.existsSync(dirPath)) {
return [];
}
const files = [];
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRECTORY_NAMES.has(entry.name)) {
continue;
}
const childFiles = listFilesRecursive(absolutePath);
for (const childFile of childFiles) {
files.push(path.join(entry.name, childFile));
}
} else if (entry.isFile()) {
files.push(entry.name);
}
}
return files.sort();
}
function isGeneratedRuntimeSourcePath(sourceRelativePath) {
const normalizedPath = String(sourceRelativePath || '').replace(/\\/g, '/');
return EXCLUDED_GENERATED_SOURCE_SUFFIXES.some(suffix => normalizedPath.endsWith(suffix));
}
function createStatePreview(options) {
const { createInstallState } = require('./install-state');
return createInstallState(options);
}
function applyInstallPlan(plan) {
const { applyInstallPlan: applyPlan } = require('./install/apply');
return applyPlan(plan);
}
function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) {
return {
kind: 'copy-file',
moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy,
ownership: 'managed',
scaffoldOnly: false,
};
}
function addRecursiveCopyOperations(operations, options) {
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
if (!fs.existsSync(sourceDir)) {
return 0;
}
const relativeFiles = listFilesRecursive(sourceDir);
for (const relativeFile of relativeFiles) {
const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile);
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
const destinationPath = path.join(options.destinationDir, relativeFile);
operations.push(buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'preserve-relative-path',
}));
}
return relativeFiles.length;
}
function addFileCopyOperation(operations, options) {
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
if (!fs.existsSync(sourcePath)) {
return false;
}
operations.push(buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath: options.sourceRelativePath,
destinationPath: options.destinationPath,
strategy: options.strategy || 'preserve-relative-path',
}));
return true;
}
function addMatchingRuleOperations(operations, options) {
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
if (!fs.existsSync(sourceDir)) {
return 0;
}
const files = fs.readdirSync(sourceDir, { withFileTypes: true })
.filter(entry => entry.isFile() && options.matcher(entry.name))
.map(entry => entry.name)
.sort();
for (const fileName of files) {
const sourceRelativePath = path.join(options.sourceRelativeDir, fileName);
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
const destinationPath = path.join(
options.destinationDir,
options.rename ? options.rename(fileName) : fileName
);
operations.push(buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'flatten-copy',
}));
}
return files.length;
}
function isDirectoryNonEmpty(dirPath) {
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory() && fs.readdirSync(dirPath).length > 0;
}
function planClaudeLegacyInstall(context) {
const adapter = getInstallTargetAdapter('claude');
const targetRoot = adapter.resolveRoot({ homeDir: context.homeDir });
const rulesDir = context.claudeRulesDir || path.join(targetRoot, 'rules');
const installStatePath = adapter.getInstallStatePath({ homeDir: context.homeDir });
const operations = [];
const warnings = [];
if (isDirectoryNonEmpty(rulesDir)) {
warnings.push(
`Destination ${rulesDir}/ already exists and files may be overwritten`
);
}
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-claude-rules',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', 'common'),
destinationDir: path.join(rulesDir, 'common'),
});
for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push(
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
);
continue;
}
const sourceDir = path.join(context.sourceRoot, 'rules', language);
if (!fs.existsSync(sourceDir)) {
warnings.push(`rules/${language}/ does not exist, skipping`);
continue;
}
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-claude-rules',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', language),
destinationDir: path.join(rulesDir, language),
});
}
return {
mode: 'legacy',
adapter,
target: 'claude',
targetRoot,
installRoot: rulesDir,
installStatePath,
operations,
warnings,
selectedModules: ['legacy-claude-rules'],
};
}
function planCursorLegacyInstall(context) {
const adapter = getInstallTargetAdapter('cursor');
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
const operations = [];
const warnings = [];
addMatchingRuleOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'rules'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => /^common-.*\.md$/.test(fileName),
});
for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push(
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
);
continue;
}
const matches = addMatchingRuleOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'rules'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md'),
});
if (matches === 0) {
warnings.push(`No Cursor rules for '${language}' found, skipping`);
}
}
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'agents'),
destinationDir: path.join(targetRoot, 'agents'),
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'skills'),
destinationDir: path.join(targetRoot, 'skills'),
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'commands'),
destinationDir: path.join(targetRoot, 'commands'),
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'hooks'),
destinationDir: path.join(targetRoot, 'hooks'),
});
addFileCopyOperation(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativePath: path.join('.cursor', 'hooks.json'),
destinationPath: path.join(targetRoot, 'hooks.json'),
});
addFileCopyOperation(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativePath: path.join('.cursor', 'mcp.json'),
destinationPath: path.join(targetRoot, 'mcp.json'),
});
return {
mode: 'legacy',
adapter,
target: 'cursor',
targetRoot,
installRoot: targetRoot,
installStatePath,
operations,
warnings,
selectedModules: ['legacy-cursor-install'],
};
}
function planAntigravityLegacyInstall(context) {
const adapter = getInstallTargetAdapter('antigravity');
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
const operations = [];
const warnings = [];
if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) {
warnings.push(
`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`
);
}
addMatchingRuleOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', 'common'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.endsWith('.md'),
rename: fileName => `common-${fileName}`,
});
for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push(
`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`
);
continue;
}
const sourceDir = path.join(context.sourceRoot, 'rules', language);
if (!fs.existsSync(sourceDir)) {
warnings.push(`rules/${language}/ does not exist, skipping`);
continue;
}
addMatchingRuleOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', language),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.endsWith('.md'),
rename: fileName => `${language}-${fileName}`,
});
}
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'commands',
destinationDir: path.join(targetRoot, 'workflows'),
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'agents',
destinationDir: path.join(targetRoot, 'skills'),
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'skills',
destinationDir: path.join(targetRoot, 'skills'),
});
return {
mode: 'legacy',
adapter,
target: 'antigravity',
targetRoot,
installRoot: targetRoot,
installStatePath,
operations,
warnings,
selectedModules: ['legacy-antigravity-install'],
};
}
function createLegacyInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
const homeDir = options.homeDir || process.env.HOME;
const target = options.target || 'claude';
validateLegacyTarget(target);
const context = {
sourceRoot,
projectRoot,
homeDir,
languages: Array.isArray(options.languages) ? options.languages : [],
claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null,
};
let plan;
if (target === 'claude') {
plan = planClaudeLegacyInstall(context);
} else if (target === 'cursor') {
plan = planCursorLegacyInstall(context);
} else {
plan = planAntigravityLegacyInstall(context);
}
const source = {
repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot),
};
const statePreview = createStatePreview({
adapter: plan.adapter,
targetRoot: plan.targetRoot,
installStatePath: plan.installStatePath,
request: {
profile: null,
modules: [],
legacyLanguages: context.languages,
legacyMode: true,
},
resolution: {
selectedModules: plan.selectedModules,
skippedModules: [],
},
operations: plan.operations,
source,
});
return {
mode: 'legacy',
target: plan.target,
adapter: {
id: plan.adapter.id,
target: plan.adapter.target,
kind: plan.adapter.kind,
},
targetRoot: plan.targetRoot,
installRoot: plan.installRoot,
installStatePath: plan.installStatePath,
warnings: plan.warnings,
languages: context.languages,
operations: plan.operations,
statePreview,
};
}
function createLegacyCompatInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
const target = options.target || 'claude';
validateLegacyTarget(target);
const selection = resolveLegacyCompatibilitySelection({
repoRoot: sourceRoot,
target,
legacyLanguages: options.legacyLanguages || [],
});
return createManifestInstallPlan({
sourceRoot,
projectRoot,
homeDir: options.homeDir,
target,
profileId: null,
moduleIds: selection.moduleIds,
includeComponentIds: [],
excludeComponentIds: [],
legacyLanguages: selection.legacyLanguages,
legacyMode: true,
requestProfileId: null,
requestModuleIds: [],
requestIncludeComponentIds: [],
requestExcludeComponentIds: [],
mode: 'legacy-compat',
});
}
function materializeScaffoldOperation(sourceRoot, operation) {
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
if (!fs.existsSync(sourcePath)) {
return [];
}
if (isGeneratedRuntimeSourcePath(operation.sourceRelativePath)) {
return [];
}
const stat = fs.statSync(sourcePath);
if (stat.isFile()) {
return [buildCopyFileOperation({
moduleId: operation.moduleId,
sourcePath,
sourceRelativePath: operation.sourceRelativePath,
destinationPath: operation.destinationPath,
strategy: operation.strategy,
})];
}
const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => {
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
return !isGeneratedRuntimeSourcePath(sourceRelativePath);
});
return relativeFiles.map(relativeFile => {
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
return buildCopyFileOperation({
moduleId: operation.moduleId,
sourcePath: path.join(sourcePath, relativeFile),
sourceRelativePath,
destinationPath: path.join(operation.destinationPath, relativeFile),
strategy: operation.strategy,
});
});
}
function createManifestInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
const target = options.target || 'claude';
const legacyLanguages = Array.isArray(options.legacyLanguages)
? [...options.legacyLanguages]
: [];
const requestProfileId = Object.hasOwn(options, 'requestProfileId')
? options.requestProfileId
: (options.profileId || null);
const requestModuleIds = Object.hasOwn(options, 'requestModuleIds')
? [...options.requestModuleIds]
: (Array.isArray(options.moduleIds) ? [...options.moduleIds] : []);
const requestIncludeComponentIds = Object.hasOwn(options, 'requestIncludeComponentIds')
? [...options.requestIncludeComponentIds]
: (Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : []);
const requestExcludeComponentIds = Object.hasOwn(options, 'requestExcludeComponentIds')
? [...options.requestExcludeComponentIds]
: (Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : []);
const plan = resolveInstallPlan({
repoRoot: sourceRoot,
projectRoot,
homeDir: options.homeDir,
profileId: options.profileId || null,
moduleIds: options.moduleIds || [],
includeComponentIds: options.includeComponentIds || [],
excludeComponentIds: options.excludeComponentIds || [],
target,
});
const adapter = getInstallTargetAdapter(target);
const operations = plan.operations.flatMap(operation => materializeScaffoldOperation(sourceRoot, operation));
const source = {
repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot),
};
const statePreview = createStatePreview({
adapter,
targetRoot: plan.targetRoot,
installStatePath: plan.installStatePath,
request: {
profile: requestProfileId,
modules: requestModuleIds,
includeComponents: requestIncludeComponentIds,
excludeComponents: requestExcludeComponentIds,
legacyLanguages,
legacyMode: Boolean(options.legacyMode),
},
resolution: {
selectedModules: plan.selectedModuleIds,
skippedModules: plan.skippedModuleIds,
},
operations,
source,
});
return {
mode: options.mode || 'manifest',
target,
adapter: {
id: adapter.id,
target: adapter.target,
kind: adapter.kind,
},
targetRoot: plan.targetRoot,
installRoot: plan.targetRoot,
installStatePath: plan.installStatePath,
warnings: Array.isArray(options.warnings) ? [...options.warnings] : [],
languages: legacyLanguages,
legacyLanguages,
profileId: plan.profileId,
requestedModuleIds: plan.requestedModuleIds,
explicitModuleIds: plan.explicitModuleIds,
includedComponentIds: plan.includedComponentIds,
excludedComponentIds: plan.excludedComponentIds,
selectedModuleIds: plan.selectedModuleIds,
skippedModuleIds: plan.skippedModuleIds,
excludedModuleIds: plan.excludedModuleIds,
operations,
statePreview,
};
}
module.exports = {
SUPPORTED_INSTALL_TARGETS,
LEGACY_INSTALL_TARGETS,
applyInstallPlan,
createLegacyCompatInstallPlan,
createManifestInstallPlan,
createLegacyInstallPlan,
getSourceRoot,
listAvailableLanguages,
parseInstallArgs,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,488 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { planInstallTargetScaffold } = require('./install-targets/registry');
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
const SUPPORTED_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity', 'codex', 'opencode'];
const COMPONENT_FAMILY_PREFIXES = {
baseline: 'baseline:',
language: 'lang:',
framework: 'framework:',
capability: 'capability:',
agent: 'agent:',
skill: 'skill:',
};
const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({
claude: [
'rules-core',
'agents-core',
'commands-core',
'hooks-runtime',
'platform-configs',
'workflow-quality',
],
cursor: [
'rules-core',
'agents-core',
'commands-core',
'hooks-runtime',
'platform-configs',
'workflow-quality',
],
antigravity: [
'rules-core',
'agents-core',
'commands-core',
],
});
const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
cpp: 'cpp',
csharp: 'csharp',
go: 'go',
golang: 'go',
java: 'java',
javascript: 'typescript',
kotlin: 'java',
perl: 'perl',
php: 'php',
python: 'python',
rust: 'rust',
swift: 'swift',
typescript: 'typescript',
});
const LEGACY_LANGUAGE_EXTRA_MODULE_IDS = Object.freeze({
cpp: ['framework-language'],
csharp: ['framework-language'],
go: ['framework-language'],
java: ['framework-language'],
perl: [],
php: [],
python: ['framework-language'],
rust: ['framework-language'],
swift: [],
typescript: ['framework-language'],
});
function readJson(filePath, label) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`Failed to read ${label}: ${error.message}`);
}
}
function dedupeStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
}
function assertKnownModuleIds(moduleIds, manifests) {
const unknownModuleIds = dedupeStrings(moduleIds)
.filter(moduleId => !manifests.modulesById.has(moduleId));
if (unknownModuleIds.length === 1) {
throw new Error(`Unknown install module: ${unknownModuleIds[0]}`);
}
if (unknownModuleIds.length > 1) {
throw new Error(`Unknown install modules: ${unknownModuleIds.join(', ')}`);
}
}
function intersectTargets(modules) {
if (!Array.isArray(modules) || modules.length === 0) {
return [];
}
return SUPPORTED_INSTALL_TARGETS.filter(target => (
modules.every(module => Array.isArray(module.targets) && module.targets.includes(target))
));
}
function getManifestPaths(repoRoot = DEFAULT_REPO_ROOT) {
return {
modulesPath: path.join(repoRoot, 'manifests', 'install-modules.json'),
profilesPath: path.join(repoRoot, 'manifests', 'install-profiles.json'),
componentsPath: path.join(repoRoot, 'manifests', 'install-components.json'),
};
}
function loadInstallManifests(options = {}) {
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
const { modulesPath, profilesPath, componentsPath } = getManifestPaths(repoRoot);
if (!fs.existsSync(modulesPath) || !fs.existsSync(profilesPath)) {
throw new Error(`Install manifests not found under ${repoRoot}`);
}
const modulesData = readJson(modulesPath, 'install-modules.json');
const profilesData = readJson(profilesPath, 'install-profiles.json');
const componentsData = fs.existsSync(componentsPath)
? readJson(componentsPath, 'install-components.json')
: { version: null, components: [] };
const modules = Array.isArray(modulesData.modules) ? modulesData.modules : [];
const profiles = profilesData && typeof profilesData.profiles === 'object'
? profilesData.profiles
: {};
const components = Array.isArray(componentsData.components) ? componentsData.components : [];
const modulesById = new Map(modules.map(module => [module.id, module]));
const componentsById = new Map(components.map(component => [component.id, component]));
return {
repoRoot,
modulesPath,
profilesPath,
componentsPath,
modules,
profiles,
components,
modulesById,
componentsById,
modulesVersion: modulesData.version,
profilesVersion: profilesData.version,
componentsVersion: componentsData.version,
};
}
function listInstallProfiles(options = {}) {
const manifests = loadInstallManifests(options);
return Object.entries(manifests.profiles).map(([id, profile]) => ({
id,
description: profile.description,
moduleCount: Array.isArray(profile.modules) ? profile.modules.length : 0,
}));
}
function listInstallModules(options = {}) {
const manifests = loadInstallManifests(options);
return manifests.modules.map(module => ({
id: module.id,
kind: module.kind,
description: module.description,
targets: module.targets,
defaultInstall: module.defaultInstall,
cost: module.cost,
stability: module.stability,
dependencyCount: Array.isArray(module.dependencies) ? module.dependencies.length : 0,
}));
}
function listLegacyCompatibilityLanguages() {
return Object.keys(LEGACY_LANGUAGE_ALIAS_TO_CANONICAL).sort();
}
function validateInstallModuleIds(moduleIds, options = {}) {
const manifests = loadInstallManifests(options);
const normalizedModuleIds = dedupeStrings(moduleIds);
assertKnownModuleIds(normalizedModuleIds, manifests);
return normalizedModuleIds;
}
function listInstallComponents(options = {}) {
const manifests = loadInstallManifests(options);
const family = options.family || null;
const target = options.target || null;
if (family && !Object.hasOwn(COMPONENT_FAMILY_PREFIXES, family)) {
throw new Error(
`Unknown component family: ${family}. Expected one of ${Object.keys(COMPONENT_FAMILY_PREFIXES).join(', ')}`
);
}
if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) {
throw new Error(
`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`
);
}
return manifests.components
.filter(component => !family || component.family === family)
.map(component => {
const moduleIds = dedupeStrings(component.modules);
const modules = moduleIds
.map(moduleId => manifests.modulesById.get(moduleId))
.filter(Boolean);
const targets = intersectTargets(modules);
return {
id: component.id,
family: component.family,
description: component.description,
moduleIds,
moduleCount: moduleIds.length,
targets,
};
})
.filter(component => !target || component.targets.includes(target));
}
function getInstallComponent(componentId, options = {}) {
const manifests = loadInstallManifests(options);
const normalizedComponentId = String(componentId || '').trim();
if (!normalizedComponentId) {
throw new Error('An install component ID is required');
}
const component = manifests.componentsById.get(normalizedComponentId);
if (!component) {
throw new Error(`Unknown install component: ${normalizedComponentId}`);
}
const moduleIds = dedupeStrings(component.modules);
const modules = moduleIds
.map(moduleId => manifests.modulesById.get(moduleId))
.filter(Boolean)
.map(module => ({
id: module.id,
kind: module.kind,
description: module.description,
targets: module.targets,
defaultInstall: module.defaultInstall,
cost: module.cost,
stability: module.stability,
dependencies: dedupeStrings(module.dependencies),
}));
return {
id: component.id,
family: component.family,
description: component.description,
moduleIds,
moduleCount: moduleIds.length,
targets: intersectTargets(modules),
modules,
};
}
function expandComponentIdsToModuleIds(componentIds, manifests) {
const expandedModuleIds = [];
for (const componentId of dedupeStrings(componentIds)) {
const component = manifests.componentsById.get(componentId);
if (!component) {
throw new Error(`Unknown install component: ${componentId}`);
}
expandedModuleIds.push(...component.modules);
}
return dedupeStrings(expandedModuleIds);
}
function resolveLegacyCompatibilitySelection(options = {}) {
const manifests = loadInstallManifests(options);
const target = options.target || null;
if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) {
throw new Error(
`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`
);
}
const legacyLanguages = dedupeStrings(options.legacyLanguages)
.map(language => language.toLowerCase());
const normalizedLegacyLanguages = dedupeStrings(legacyLanguages);
if (normalizedLegacyLanguages.length === 0) {
throw new Error('No legacy languages were provided');
}
const unknownLegacyLanguages = normalizedLegacyLanguages
.filter(language => !Object.hasOwn(LEGACY_LANGUAGE_ALIAS_TO_CANONICAL, language));
if (unknownLegacyLanguages.length === 1) {
throw new Error(
`Unknown legacy language: ${unknownLegacyLanguages[0]}. Expected one of ${listLegacyCompatibilityLanguages().join(', ')}`
);
}
if (unknownLegacyLanguages.length > 1) {
throw new Error(
`Unknown legacy languages: ${unknownLegacyLanguages.join(', ')}. Expected one of ${listLegacyCompatibilityLanguages().join(', ')}`
);
}
const canonicalLegacyLanguages = normalizedLegacyLanguages
.map(language => LEGACY_LANGUAGE_ALIAS_TO_CANONICAL[language]);
const baseModuleIds = LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET[target || 'claude']
|| LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET.claude;
const moduleIds = dedupeStrings([
...baseModuleIds,
...(target === 'antigravity'
? []
: canonicalLegacyLanguages.flatMap(language => LEGACY_LANGUAGE_EXTRA_MODULE_IDS[language] || [])),
]);
assertKnownModuleIds(moduleIds, manifests);
return {
legacyLanguages: normalizedLegacyLanguages,
canonicalLegacyLanguages,
moduleIds,
};
}
function resolveInstallPlan(options = {}) {
const manifests = loadInstallManifests(options);
const profileId = options.profileId || null;
const explicitModuleIds = dedupeStrings(options.moduleIds);
const includedComponentIds = dedupeStrings(options.includeComponentIds);
const excludedComponentIds = dedupeStrings(options.excludeComponentIds);
const requestedModuleIds = [];
if (profileId) {
const profile = manifests.profiles[profileId];
if (!profile) {
throw new Error(`Unknown install profile: ${profileId}`);
}
requestedModuleIds.push(...profile.modules);
}
requestedModuleIds.push(...explicitModuleIds);
requestedModuleIds.push(...expandComponentIdsToModuleIds(includedComponentIds, manifests));
const excludedModuleIds = expandComponentIdsToModuleIds(excludedComponentIds, manifests);
const excludedModuleOwners = new Map();
for (const componentId of excludedComponentIds) {
const component = manifests.componentsById.get(componentId);
if (!component) {
throw new Error(`Unknown install component: ${componentId}`);
}
for (const moduleId of component.modules) {
const owners = excludedModuleOwners.get(moduleId) || [];
owners.push(componentId);
excludedModuleOwners.set(moduleId, owners);
}
}
const target = options.target || null;
if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) {
throw new Error(
`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`
);
}
const effectiveRequestedIds = dedupeStrings(
requestedModuleIds.filter(moduleId => !excludedModuleOwners.has(moduleId))
);
if (requestedModuleIds.length === 0) {
throw new Error('No install profile, module IDs, or included component IDs were provided');
}
if (effectiveRequestedIds.length === 0) {
throw new Error('Selection excludes every requested install module');
}
const selectedIds = new Set();
const skippedTargetIds = new Set();
const excludedIds = new Set(excludedModuleIds);
const visitingIds = new Set();
const resolvedIds = new Set();
function resolveModule(moduleId, dependencyOf, rootRequesterId) {
const module = manifests.modulesById.get(moduleId);
if (!module) {
throw new Error(`Unknown install module: ${moduleId}`);
}
if (excludedModuleOwners.has(moduleId)) {
if (dependencyOf) {
const owners = excludedModuleOwners.get(moduleId) || [];
throw new Error(
`Module ${dependencyOf} depends on excluded module ${moduleId}${owners.length > 0 ? ` (excluded by ${owners.join(', ')})` : ''}`
);
}
return;
}
if (target && !module.targets.includes(target)) {
if (dependencyOf) {
skippedTargetIds.add(rootRequesterId || dependencyOf);
return false;
}
skippedTargetIds.add(moduleId);
return false;
}
if (resolvedIds.has(moduleId)) {
return true;
}
if (visitingIds.has(moduleId)) {
throw new Error(`Circular install dependency detected at ${moduleId}`);
}
visitingIds.add(moduleId);
for (const dependencyId of module.dependencies) {
const dependencyResolved = resolveModule(
dependencyId,
moduleId,
rootRequesterId || moduleId
);
if (!dependencyResolved) {
visitingIds.delete(moduleId);
if (!dependencyOf) {
skippedTargetIds.add(moduleId);
}
return false;
}
}
visitingIds.delete(moduleId);
resolvedIds.add(moduleId);
selectedIds.add(moduleId);
return true;
}
for (const moduleId of effectiveRequestedIds) {
resolveModule(moduleId, null, moduleId);
}
const selectedModules = manifests.modules.filter(module => selectedIds.has(module.id));
const skippedModules = manifests.modules.filter(module => skippedTargetIds.has(module.id));
const excludedModules = manifests.modules.filter(module => excludedIds.has(module.id));
const scaffoldPlan = target
? planInstallTargetScaffold({
target,
repoRoot: manifests.repoRoot,
projectRoot: options.projectRoot || manifests.repoRoot,
homeDir: options.homeDir || os.homedir(),
modules: selectedModules,
})
: null;
return {
repoRoot: manifests.repoRoot,
profileId,
target,
requestedModuleIds: effectiveRequestedIds,
explicitModuleIds,
includedComponentIds,
excludedComponentIds,
selectedModuleIds: selectedModules.map(module => module.id),
skippedModuleIds: skippedModules.map(module => module.id),
excludedModuleIds: excludedModules.map(module => module.id),
selectedModules,
skippedModules,
excludedModules,
targetAdapterId: scaffoldPlan ? scaffoldPlan.adapter.id : null,
targetRoot: scaffoldPlan ? scaffoldPlan.targetRoot : null,
installStatePath: scaffoldPlan ? scaffoldPlan.installStatePath : null,
operations: scaffoldPlan ? scaffoldPlan.operations : [],
};
}
module.exports = {
DEFAULT_REPO_ROOT,
SUPPORTED_INSTALL_TARGETS,
getManifestPaths,
loadInstallManifests,
getInstallComponent,
listInstallComponents,
listLegacyCompatibilityLanguages,
listInstallModules,
listInstallProfiles,
resolveInstallPlan,
resolveLegacyCompatibilitySelection,
validateInstallModuleIds,
};

View File

@@ -0,0 +1,313 @@
const fs = require('fs');
const path = require('path');
let Ajv = null;
try {
// Prefer schema-backed validation when dependencies are installed.
// The fallback validator below keeps source checkouts usable in bare environments.
const ajvModule = require('ajv');
Ajv = ajvModule.default || ajvModule;
} catch (_error) {
Ajv = null;
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'schemas', 'install-state.schema.json');
let cachedValidator = null;
function cloneJsonValue(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function readJson(filePath, label) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`Failed to read ${label}: ${error.message}`);
}
}
function getValidator() {
if (cachedValidator) {
return cachedValidator;
}
if (Ajv) {
const schema = readJson(SCHEMA_PATH, 'install-state schema');
const ajv = new Ajv({ allErrors: true });
cachedValidator = ajv.compile(schema);
return cachedValidator;
}
cachedValidator = createFallbackValidator();
return cachedValidator;
}
function createFallbackValidator() {
const validate = state => {
const errors = [];
validate.errors = errors;
function pushError(instancePath, message) {
errors.push({
instancePath,
message,
});
}
function isNonEmptyString(value) {
return typeof value === 'string' && value.length > 0;
}
function validateNoAdditionalProperties(value, instancePath, allowedKeys) {
for (const key of Object.keys(value)) {
if (!allowedKeys.includes(key)) {
pushError(`${instancePath}/${key}`, 'must NOT have additional properties');
}
}
}
function validateStringArray(value, instancePath) {
if (!Array.isArray(value)) {
pushError(instancePath, 'must be array');
return;
}
for (let index = 0; index < value.length; index += 1) {
if (!isNonEmptyString(value[index])) {
pushError(`${instancePath}/${index}`, 'must be non-empty string');
}
}
}
function validateOptionalString(value, instancePath) {
if (value !== undefined && value !== null && !isNonEmptyString(value)) {
pushError(instancePath, 'must be string or null');
}
}
if (!state || typeof state !== 'object' || Array.isArray(state)) {
pushError('/', 'must be object');
return false;
}
validateNoAdditionalProperties(
state,
'',
['schemaVersion', 'installedAt', 'lastValidatedAt', 'target', 'request', 'resolution', 'source', 'operations']
);
if (state.schemaVersion !== 'ecc.install.v1') {
pushError('/schemaVersion', 'must equal ecc.install.v1');
}
if (!isNonEmptyString(state.installedAt)) {
pushError('/installedAt', 'must be non-empty string');
}
if (state.lastValidatedAt !== undefined && !isNonEmptyString(state.lastValidatedAt)) {
pushError('/lastValidatedAt', 'must be non-empty string');
}
const target = state.target;
if (!target || typeof target !== 'object' || Array.isArray(target)) {
pushError('/target', 'must be object');
} else {
validateNoAdditionalProperties(target, '/target', ['id', 'target', 'kind', 'root', 'installStatePath']);
if (!isNonEmptyString(target.id)) {
pushError('/target/id', 'must be non-empty string');
}
validateOptionalString(target.target, '/target/target');
if (target.kind !== undefined && !['home', 'project'].includes(target.kind)) {
pushError('/target/kind', 'must be equal to one of the allowed values');
}
if (!isNonEmptyString(target.root)) {
pushError('/target/root', 'must be non-empty string');
}
if (!isNonEmptyString(target.installStatePath)) {
pushError('/target/installStatePath', 'must be non-empty string');
}
}
const request = state.request;
if (!request || typeof request !== 'object' || Array.isArray(request)) {
pushError('/request', 'must be object');
} else {
validateNoAdditionalProperties(
request,
'/request',
['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode']
);
if (!(Object.prototype.hasOwnProperty.call(request, 'profile') && (request.profile === null || typeof request.profile === 'string'))) {
pushError('/request/profile', 'must be string or null');
}
validateStringArray(request.modules, '/request/modules');
validateStringArray(request.includeComponents, '/request/includeComponents');
validateStringArray(request.excludeComponents, '/request/excludeComponents');
validateStringArray(request.legacyLanguages, '/request/legacyLanguages');
if (typeof request.legacyMode !== 'boolean') {
pushError('/request/legacyMode', 'must be boolean');
}
}
const resolution = state.resolution;
if (!resolution || typeof resolution !== 'object' || Array.isArray(resolution)) {
pushError('/resolution', 'must be object');
} else {
validateNoAdditionalProperties(resolution, '/resolution', ['selectedModules', 'skippedModules']);
validateStringArray(resolution.selectedModules, '/resolution/selectedModules');
validateStringArray(resolution.skippedModules, '/resolution/skippedModules');
}
const source = state.source;
if (!source || typeof source !== 'object' || Array.isArray(source)) {
pushError('/source', 'must be object');
} else {
validateNoAdditionalProperties(source, '/source', ['repoVersion', 'repoCommit', 'manifestVersion']);
validateOptionalString(source.repoVersion, '/source/repoVersion');
validateOptionalString(source.repoCommit, '/source/repoCommit');
if (!Number.isInteger(source.manifestVersion) || source.manifestVersion < 1) {
pushError('/source/manifestVersion', 'must be integer >= 1');
}
}
if (!Array.isArray(state.operations)) {
pushError('/operations', 'must be array');
} else {
for (let index = 0; index < state.operations.length; index += 1) {
const operation = state.operations[index];
const instancePath = `/operations/${index}`;
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) {
pushError(instancePath, 'must be object');
continue;
}
if (!isNonEmptyString(operation.kind)) {
pushError(`${instancePath}/kind`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.moduleId)) {
pushError(`${instancePath}/moduleId`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.sourceRelativePath)) {
pushError(`${instancePath}/sourceRelativePath`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.destinationPath)) {
pushError(`${instancePath}/destinationPath`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.strategy)) {
pushError(`${instancePath}/strategy`, 'must be non-empty string');
}
if (!isNonEmptyString(operation.ownership)) {
pushError(`${instancePath}/ownership`, 'must be non-empty string');
}
if (typeof operation.scaffoldOnly !== 'boolean') {
pushError(`${instancePath}/scaffoldOnly`, 'must be boolean');
}
}
}
return errors.length === 0;
};
validate.errors = [];
return validate;
}
function formatValidationErrors(errors = []) {
return errors
.map(error => `${error.instancePath || '/'} ${error.message}`)
.join('; ');
}
function validateInstallState(state) {
const validator = getValidator();
const valid = validator(state);
return {
valid,
errors: validator.errors || [],
};
}
function assertValidInstallState(state, label) {
const result = validateInstallState(state);
if (!result.valid) {
throw new Error(`Invalid install-state${label ? ` (${label})` : ''}: ${formatValidationErrors(result.errors)}`);
}
}
function createInstallState(options) {
const installedAt = options.installedAt || new Date().toISOString();
const state = {
schemaVersion: 'ecc.install.v1',
installedAt,
target: {
id: options.adapter.id,
target: options.adapter.target || undefined,
kind: options.adapter.kind || undefined,
root: options.targetRoot,
installStatePath: options.installStatePath,
},
request: {
profile: options.request.profile || null,
modules: Array.isArray(options.request.modules) ? [...options.request.modules] : [],
includeComponents: Array.isArray(options.request.includeComponents)
? [...options.request.includeComponents]
: [],
excludeComponents: Array.isArray(options.request.excludeComponents)
? [...options.request.excludeComponents]
: [],
legacyLanguages: Array.isArray(options.request.legacyLanguages)
? [...options.request.legacyLanguages]
: [],
legacyMode: Boolean(options.request.legacyMode),
},
resolution: {
selectedModules: Array.isArray(options.resolution.selectedModules)
? [...options.resolution.selectedModules]
: [],
skippedModules: Array.isArray(options.resolution.skippedModules)
? [...options.resolution.skippedModules]
: [],
},
source: {
repoVersion: options.source.repoVersion || null,
repoCommit: options.source.repoCommit || null,
manifestVersion: options.source.manifestVersion,
},
operations: Array.isArray(options.operations)
? options.operations.map(operation => cloneJsonValue(operation))
: [],
};
if (options.lastValidatedAt) {
state.lastValidatedAt = options.lastValidatedAt;
}
assertValidInstallState(state, 'create');
return state;
}
function readInstallState(filePath) {
const state = readJson(filePath, 'install-state');
assertValidInstallState(state, filePath);
return state;
}
function writeInstallState(filePath, state) {
assertValidInstallState(state, filePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`);
return state;
}
module.exports = {
createInstallState,
readInstallState,
validateInstallState,
writeInstallState,
};

View File

@@ -0,0 +1,69 @@
const path = require('path');
const {
createFlatRuleOperations,
createInstallTargetAdapter,
createManagedScaffoldOperation,
} = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'antigravity-project',
target: 'antigravity',
kind: 'project',
rootSegments: ['.agent'],
installStatePathSegments: ['ecc-install-state.json'],
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
? input.modules
: (input.module ? [input.module] : []);
const {
repoRoot,
projectRoot,
homeDir,
} = input;
const planningInput = {
repoRoot,
projectRoot,
homeDir,
};
const targetRoot = adapter.resolveRoot(planningInput);
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.flatMap(sourceRelativePath => {
if (sourceRelativePath === 'rules') {
return createFlatRuleOperations({
moduleId: module.id,
repoRoot,
sourceRelativePath,
destinationDir: path.join(targetRoot, 'rules'),
});
}
if (sourceRelativePath === 'commands') {
return [
createManagedScaffoldOperation(
module.id,
sourceRelativePath,
path.join(targetRoot, 'workflows'),
'preserve-relative-path'
),
];
}
if (sourceRelativePath === 'agents') {
return [
createManagedScaffoldOperation(
module.id,
sourceRelativePath,
path.join(targetRoot, 'skills'),
'preserve-relative-path'
),
];
}
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
});
},
});

View File

@@ -0,0 +1,10 @@
const { createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'claude-home',
target: 'claude',
kind: 'home',
rootSegments: ['.claude'],
installStatePathSegments: ['ecc', 'install-state.json'],
nativeRootRelativePath: '.claude-plugin',
});

View File

@@ -0,0 +1,10 @@
const { createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'codex-home',
target: 'codex',
kind: 'home',
rootSegments: ['.codex'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.codex',
});

View File

@@ -0,0 +1,47 @@
const path = require('path');
const {
createFlatRuleOperations,
createInstallTargetAdapter,
} = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'cursor-project',
target: 'cursor',
kind: 'project',
rootSegments: ['.cursor'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.cursor',
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
? input.modules
: (input.module ? [input.module] : []);
const {
repoRoot,
projectRoot,
homeDir,
} = input;
const planningInput = {
repoRoot,
projectRoot,
homeDir,
};
const targetRoot = adapter.resolveRoot(planningInput);
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.flatMap(sourceRelativePath => {
if (sourceRelativePath === 'rules') {
return createFlatRuleOperations({
moduleId: module.id,
repoRoot,
sourceRelativePath,
destinationDir: path.join(targetRoot, 'rules'),
});
}
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
});
},
});

View File

@@ -0,0 +1,307 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
function normalizeRelativePath(relativePath) {
return String(relativePath || '')
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/\/+$/, '');
}
function resolveBaseRoot(scope, input = {}) {
if (scope === 'home') {
return input.homeDir || os.homedir();
}
if (scope === 'project') {
const projectRoot = input.projectRoot || input.repoRoot;
if (!projectRoot) {
throw new Error('projectRoot or repoRoot is required for project install targets');
}
return projectRoot;
}
throw new Error(`Unsupported install target scope: ${scope}`);
}
function buildValidationIssue(severity, code, message, extra = {}) {
return {
severity,
code,
message,
...extra,
};
}
function listRelativeFiles(dirPath, prefix = '') {
if (!fs.existsSync(dirPath)) {
return [];
}
const entries = fs.readdirSync(dirPath, { withFileTypes: true }).sort((left, right) => (
left.name.localeCompare(right.name)
));
const files = [];
for (const entry of entries) {
const entryPrefix = prefix ? path.join(prefix, entry.name) : entry.name;
const absolutePath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...listRelativeFiles(absolutePath, entryPrefix));
} else if (entry.isFile()) {
files.push(normalizeRelativePath(entryPrefix));
}
}
return files;
}
function createManagedOperation({
kind = 'copy-path',
moduleId,
sourceRelativePath,
destinationPath,
strategy = 'preserve-relative-path',
ownership = 'managed',
scaffoldOnly = true,
...rest
}) {
return {
kind,
moduleId,
sourceRelativePath: normalizeRelativePath(sourceRelativePath),
destinationPath,
strategy,
ownership,
scaffoldOnly,
...rest,
};
}
function defaultValidateAdapterInput(config, input = {}) {
if (config.kind === 'project' && !input.projectRoot && !input.repoRoot) {
return [
buildValidationIssue(
'error',
'missing-project-root',
'projectRoot or repoRoot is required for project install targets'
),
];
}
if (config.kind === 'home' && !input.homeDir && !os.homedir()) {
return [
buildValidationIssue(
'error',
'missing-home-dir',
'homeDir is required for home install targets'
),
];
}
return [];
}
function createRemappedOperation(adapter, moduleId, sourceRelativePath, destinationPath, options = {}) {
return createManagedOperation({
kind: options.kind || 'copy-path',
moduleId,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'preserve-relative-path',
ownership: options.ownership || 'managed',
scaffoldOnly: Object.hasOwn(options, 'scaffoldOnly') ? options.scaffoldOnly : true,
...options.extra,
});
}
function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath);
if (!input.repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
return [];
}
const targetRulesDir = path.join(adapter.resolveRoot(input), 'rules');
const operations = [];
const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => (
left.name.localeCompare(right.name)
));
for (const entry of entries) {
const namespace = entry.name;
const entryPath = path.join(sourceRoot, entry.name);
if (entry.isDirectory()) {
const relativeFiles = listRelativeFiles(entryPath);
for (const relativeFile of relativeFiles) {
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
const sourceRelativeFile = path.join(normalizedSourcePath, namespace, relativeFile);
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: sourceRelativeFile,
destinationPath: path.join(targetRulesDir, flattenedFileName),
strategy: 'flatten-copy',
}));
}
} else if (entry.isFile()) {
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
destinationPath: path.join(targetRulesDir, entry.name),
strategy: 'flatten-copy',
}));
}
}
return operations;
}
function createFlatRuleOperations({ moduleId, repoRoot, sourceRelativePath, destinationDir }) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const sourceRoot = path.join(repoRoot || '', normalizedSourcePath);
if (!repoRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
return [];
}
const operations = [];
const entries = fs.readdirSync(sourceRoot, { withFileTypes: true }).sort((left, right) => (
left.name.localeCompare(right.name)
));
for (const entry of entries) {
const namespace = entry.name;
const entryPath = path.join(sourceRoot, entry.name);
if (entry.isDirectory()) {
const relativeFiles = listRelativeFiles(entryPath);
for (const relativeFile of relativeFiles) {
const flattenedFileName = `${namespace}-${normalizeRelativePath(relativeFile).replace(/\//g, '-')}`;
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, namespace, relativeFile),
destinationPath: path.join(destinationDir, flattenedFileName),
strategy: 'flatten-copy',
}));
}
} else if (entry.isFile()) {
operations.push(createManagedOperation({
moduleId,
sourceRelativePath: path.join(normalizedSourcePath, entry.name),
destinationPath: path.join(destinationDir, entry.name),
strategy: 'flatten-copy',
}));
}
}
return operations;
}
function createInstallTargetAdapter(config) {
const adapter = {
id: config.id,
target: config.target,
kind: config.kind,
nativeRootRelativePath: config.nativeRootRelativePath || null,
supports(target) {
return target === config.target || target === config.id;
},
resolveRoot(input = {}) {
const baseRoot = resolveBaseRoot(config.kind, input);
return path.join(baseRoot, ...config.rootSegments);
},
getInstallStatePath(input = {}) {
const root = adapter.resolveRoot(input);
return path.join(root, ...config.installStatePathSegments);
},
resolveDestinationPath(sourceRelativePath, input = {}) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
const targetRoot = adapter.resolveRoot(input);
if (
config.nativeRootRelativePath
&& normalizedSourcePath === normalizeRelativePath(config.nativeRootRelativePath)
) {
return targetRoot;
}
return path.join(targetRoot, normalizedSourcePath);
},
determineStrategy(sourceRelativePath) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
if (
config.nativeRootRelativePath
&& normalizedSourcePath === normalizeRelativePath(config.nativeRootRelativePath)
) {
return 'sync-root-children';
}
return 'preserve-relative-path';
},
createScaffoldOperation(moduleId, sourceRelativePath, input = {}) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
return createManagedOperation({
moduleId,
sourceRelativePath: normalizedSourcePath,
destinationPath: adapter.resolveDestinationPath(normalizedSourcePath, input),
strategy: adapter.determineStrategy(normalizedSourcePath),
});
},
planOperations(input = {}) {
if (typeof config.planOperations === 'function') {
return config.planOperations(input, adapter);
}
if (Array.isArray(input.modules)) {
return input.modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
input
));
});
}
const module = input.module || {};
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
input
));
},
validate(input = {}) {
if (typeof config.validate === 'function') {
return config.validate(input, adapter);
}
return defaultValidateAdapterInput(config, input);
},
};
return Object.freeze(adapter);
}
module.exports = {
buildValidationIssue,
createFlatRuleOperations,
createInstallTargetAdapter,
createManagedOperation,
createManagedScaffoldOperation: (moduleId, sourceRelativePath, destinationPath, strategy) => (
createManagedOperation({
moduleId,
sourceRelativePath,
destinationPath,
strategy,
})
),
createNamespacedFlatRuleOperations,
createRemappedOperation,
normalizeRelativePath,
};

View File

@@ -0,0 +1,10 @@
const { createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'opencode-home',
target: 'opencode',
kind: 'home',
rootSegments: ['.opencode'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.opencode',
});

View File

@@ -0,0 +1,66 @@
const antigravityProject = require('./antigravity-project');
const claudeHome = require('./claude-home');
const codexHome = require('./codex-home');
const cursorProject = require('./cursor-project');
const opencodeHome = require('./opencode-home');
const ADAPTERS = Object.freeze([
claudeHome,
cursorProject,
antigravityProject,
codexHome,
opencodeHome,
]);
function listInstallTargetAdapters() {
return ADAPTERS.slice();
}
function getInstallTargetAdapter(targetOrAdapterId) {
const adapter = ADAPTERS.find(candidate => candidate.supports(targetOrAdapterId));
if (!adapter) {
throw new Error(`Unknown install target adapter: ${targetOrAdapterId}`);
}
return adapter;
}
function planInstallTargetScaffold(options = {}) {
const adapter = getInstallTargetAdapter(options.target);
const modules = Array.isArray(options.modules) ? options.modules : [];
const planningInput = {
repoRoot: options.repoRoot,
projectRoot: options.projectRoot || options.repoRoot,
homeDir: options.homeDir,
};
const validationIssues = adapter.validate(planningInput);
const blockingIssues = validationIssues.filter(issue => issue.severity === 'error');
if (blockingIssues.length > 0) {
throw new Error(blockingIssues.map(issue => issue.message).join('; '));
}
const targetRoot = adapter.resolveRoot(planningInput);
const installStatePath = adapter.getInstallStatePath(planningInput);
const operations = adapter.planOperations({
...planningInput,
modules,
});
return {
adapter: {
id: adapter.id,
target: adapter.target,
kind: adapter.kind,
},
targetRoot,
installStatePath,
validationIssues,
operations,
};
}
module.exports = {
getInstallTargetAdapter,
listInstallTargetAdapters,
planInstallTargetScaffold,
};

View File

@@ -0,0 +1,23 @@
'use strict';
const fs = require('fs');
const { writeInstallState } = require('../install-state');
function applyInstallPlan(plan) {
for (const operation of plan.operations) {
fs.mkdirSync(require('path').dirname(operation.destinationPath), { recursive: true });
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
}
writeInstallState(plan.installStatePath, plan.statePreview);
return {
...plan,
applied: true,
};
}
module.exports = {
applyInstallPlan,
};

View File

@@ -0,0 +1,89 @@
'use strict';
const fs = require('fs');
const path = require('path');
const Ajv = require('ajv');
const DEFAULT_INSTALL_CONFIG = 'ecc-install.json';
const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'ecc-install-config.schema.json');
let cachedValidator = null;
function readJson(filePath, label) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`Invalid JSON in ${label}: ${error.message}`);
}
}
function getValidator() {
if (cachedValidator) {
return cachedValidator;
}
const schema = readJson(CONFIG_SCHEMA_PATH, 'ecc-install-config.schema.json');
const ajv = new Ajv({ allErrors: true });
cachedValidator = ajv.compile(schema);
return cachedValidator;
}
function dedupeStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
}
function formatValidationErrors(errors = []) {
return errors.map(error => `${error.instancePath || '/'} ${error.message}`).join('; ');
}
function resolveInstallConfigPath(configPath, options = {}) {
if (!configPath) {
throw new Error('An install config path is required');
}
const cwd = options.cwd || process.cwd();
return path.isAbsolute(configPath)
? configPath
: path.normalize(path.join(cwd, configPath));
}
function findDefaultInstallConfigPath(options = {}) {
const cwd = options.cwd || process.cwd();
const candidatePath = path.join(cwd, DEFAULT_INSTALL_CONFIG);
return fs.existsSync(candidatePath) ? candidatePath : null;
}
function loadInstallConfig(configPath, options = {}) {
const resolvedPath = resolveInstallConfigPath(configPath, options);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Install config not found: ${resolvedPath}`);
}
const raw = readJson(resolvedPath, path.basename(resolvedPath));
const validator = getValidator();
if (!validator(raw)) {
throw new Error(
`Invalid install config ${resolvedPath}: ${formatValidationErrors(validator.errors)}`
);
}
return {
path: resolvedPath,
version: raw.version,
target: raw.target || null,
profileId: raw.profile || null,
moduleIds: dedupeStrings(raw.modules),
includeComponentIds: dedupeStrings(raw.include),
excludeComponentIds: dedupeStrings(raw.exclude),
options: raw.options && typeof raw.options === 'object' ? { ...raw.options } : {},
};
}
module.exports = {
DEFAULT_INSTALL_CONFIG,
findDefaultInstallConfigPath,
loadInstallConfig,
resolveInstallConfigPath,
};

View File

@@ -0,0 +1,120 @@
'use strict';
const { validateInstallModuleIds } = require('../install-manifests');
const LEGACY_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity'];
function dedupeStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))];
}
function parseInstallArgs(argv) {
const args = argv.slice(2);
const parsed = {
target: null,
dryRun: false,
json: false,
help: false,
configPath: null,
profileId: null,
moduleIds: [],
includeComponentIds: [],
excludeComponentIds: [],
languages: [],
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--target') {
parsed.target = args[index + 1] || null;
index += 1;
} else if (arg === '--config') {
parsed.configPath = args[index + 1] || null;
index += 1;
} else if (arg === '--profile') {
parsed.profileId = args[index + 1] || null;
index += 1;
} else if (arg === '--modules') {
const raw = args[index + 1] || '';
parsed.moduleIds = dedupeStrings(raw.split(','));
index += 1;
} else if (arg === '--with') {
const componentId = args[index + 1] || '';
if (componentId.trim()) {
parsed.includeComponentIds.push(componentId.trim());
}
index += 1;
} else if (arg === '--without') {
const componentId = args[index + 1] || '';
if (componentId.trim()) {
parsed.excludeComponentIds.push(componentId.trim());
}
index += 1;
} else if (arg === '--dry-run') {
parsed.dryRun = true;
} else if (arg === '--json') {
parsed.json = true;
} else if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else if (arg.startsWith('--')) {
throw new Error(`Unknown argument: ${arg}`);
} else {
parsed.languages.push(arg);
}
}
return parsed;
}
function normalizeInstallRequest(options = {}) {
const config = options.config && typeof options.config === 'object'
? options.config
: null;
const profileId = options.profileId || config?.profileId || null;
const moduleIds = validateInstallModuleIds(
dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])])
);
const includeComponentIds = dedupeStrings([
...(config?.includeComponentIds || []),
...(options.includeComponentIds || []),
]);
const excludeComponentIds = dedupeStrings([
...(config?.excludeComponentIds || []),
...(options.excludeComponentIds || []),
]);
const legacyLanguages = dedupeStrings(dedupeStrings([
...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []),
...(Array.isArray(options.languages) ? options.languages : []),
]).map(language => language.toLowerCase()));
const target = options.target || config?.target || 'claude';
const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0;
const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0;
if (usingManifestMode && legacyLanguages.length > 0) {
throw new Error(
'Legacy language arguments cannot be combined with --profile, --modules, --with, --without, or manifest config selections'
);
}
if (!options.help && !hasManifestBaseSelection && legacyLanguages.length === 0) {
throw new Error('No install profile, module IDs, included components, or legacy languages were provided');
}
return {
mode: usingManifestMode ? 'manifest' : 'legacy-compat',
target,
profileId,
moduleIds,
includeComponentIds,
excludeComponentIds,
legacyLanguages,
configPath: config?.path || options.configPath || null,
};
}
module.exports = {
LEGACY_INSTALL_TARGETS,
normalizeInstallRequest,
parseInstallArgs,
};

View File

@@ -0,0 +1,54 @@
'use strict';
const {
createLegacyCompatInstallPlan,
createLegacyInstallPlan,
createManifestInstallPlan,
} = require('../install-executor');
function createInstallPlanFromRequest(request, options = {}) {
if (!request || typeof request !== 'object') {
throw new Error('A normalized install request is required');
}
if (request.mode === 'manifest') {
return createManifestInstallPlan({
target: request.target,
profileId: request.profileId,
moduleIds: request.moduleIds,
includeComponentIds: request.includeComponentIds,
excludeComponentIds: request.excludeComponentIds,
projectRoot: options.projectRoot,
homeDir: options.homeDir,
sourceRoot: options.sourceRoot,
});
}
if (request.mode === 'legacy-compat') {
return createLegacyCompatInstallPlan({
target: request.target,
legacyLanguages: request.legacyLanguages,
projectRoot: options.projectRoot,
homeDir: options.homeDir,
claudeRulesDir: options.claudeRulesDir,
sourceRoot: options.sourceRoot,
});
}
if (request.mode === 'legacy') {
return createLegacyInstallPlan({
target: request.target,
languages: request.languages,
projectRoot: options.projectRoot,
homeDir: options.homeDir,
claudeRulesDir: options.claudeRulesDir,
sourceRoot: options.sourceRoot,
});
}
throw new Error(`Unsupported install request mode: ${request.mode}`);
}
module.exports = {
createInstallPlanFromRequest,
};

View File

@@ -0,0 +1,299 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function stripCodeTicks(value) {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function parseSection(content, heading) {
if (typeof content !== 'string' || content.length === 0) {
return '';
}
const lines = content.split('\n');
const headingLines = new Set([`## ${heading}`, `**${heading}**`]);
const startIndex = lines.findIndex(line => headingLines.has(line.trim()));
if (startIndex === -1) {
return '';
}
const collected = [];
for (let index = startIndex + 1; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (trimmed.startsWith('## ') || (/^\*\*.+\*\*$/.test(trimmed) && !headingLines.has(trimmed))) {
break;
}
collected.push(line);
}
return collected.join('\n').trim();
}
function parseBullets(section) {
if (!section) {
return [];
}
return section
.split('\n')
.map(line => line.trim())
.filter(line => line.startsWith('- '))
.map(line => stripCodeTicks(line.replace(/^- /, '').trim()));
}
function parseWorkerStatus(content) {
const status = {
state: null,
updated: null,
branch: null,
worktree: null,
taskFile: null,
handoffFile: null
};
if (typeof content !== 'string' || content.length === 0) {
return status;
}
for (const line of content.split('\n')) {
const match = line.match(/^- ([A-Za-z ]+):\s*(.+)$/);
if (!match) {
continue;
}
const key = match[1].trim().toLowerCase().replace(/\s+/g, '');
const value = stripCodeTicks(match[2]);
if (key === 'state') status.state = value;
if (key === 'updated') status.updated = value;
if (key === 'branch') status.branch = value;
if (key === 'worktree') status.worktree = value;
if (key === 'taskfile') status.taskFile = value;
if (key === 'handofffile') status.handoffFile = value;
}
return status;
}
function parseWorkerTask(content) {
return {
objective: parseSection(content, 'Objective'),
seedPaths: parseBullets(parseSection(content, 'Seeded Local Overlays'))
};
}
function parseWorkerHandoff(content) {
return {
summary: parseBullets(parseSection(content, 'Summary')),
validation: parseBullets(parseSection(content, 'Validation')),
remainingRisks: parseBullets(parseSection(content, 'Remaining Risks'))
};
}
function readTextIfExists(filePath) {
if (!filePath || !fs.existsSync(filePath)) {
return '';
}
return fs.readFileSync(filePath, 'utf8');
}
function listWorkerDirectories(coordinationDir) {
if (!coordinationDir || !fs.existsSync(coordinationDir)) {
return [];
}
return fs.readdirSync(coordinationDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.filter(entry => {
const workerDir = path.join(coordinationDir, entry.name);
return ['status.md', 'task.md', 'handoff.md']
.some(filename => fs.existsSync(path.join(workerDir, filename)));
})
.map(entry => entry.name)
.sort();
}
function loadWorkerSnapshots(coordinationDir) {
return listWorkerDirectories(coordinationDir).map(workerSlug => {
const workerDir = path.join(coordinationDir, workerSlug);
const statusPath = path.join(workerDir, 'status.md');
const taskPath = path.join(workerDir, 'task.md');
const handoffPath = path.join(workerDir, 'handoff.md');
const status = parseWorkerStatus(readTextIfExists(statusPath));
const task = parseWorkerTask(readTextIfExists(taskPath));
const handoff = parseWorkerHandoff(readTextIfExists(handoffPath));
return {
workerSlug,
workerDir,
status,
task,
handoff,
files: {
status: statusPath,
task: taskPath,
handoff: handoffPath
}
};
});
}
function listTmuxPanes(sessionName, options = {}) {
const { spawnSyncImpl = spawnSync } = options;
const format = [
'#{pane_id}',
'#{window_index}',
'#{pane_index}',
'#{pane_title}',
'#{pane_current_command}',
'#{pane_current_path}',
'#{pane_active}',
'#{pane_dead}',
'#{pane_pid}'
].join('\t');
const result = spawnSyncImpl('tmux', ['list-panes', '-t', sessionName, '-F', format], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
if (result.error) {
if (result.error.code === 'ENOENT') {
return [];
}
throw result.error;
}
if (result.status !== 0) {
return [];
}
return (result.stdout || '')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map(line => {
const [
paneId,
windowIndex,
paneIndex,
title,
currentCommand,
currentPath,
active,
dead,
pid
] = line.split('\t');
return {
paneId,
windowIndex: Number(windowIndex),
paneIndex: Number(paneIndex),
title,
currentCommand,
currentPath,
active: active === '1',
dead: dead === '1',
pid: pid ? Number(pid) : null
};
});
}
function summarizeWorkerStates(workers) {
return workers.reduce((counts, worker) => {
const state = worker.status.state || 'unknown';
counts[state] = (counts[state] || 0) + 1;
return counts;
}, {});
}
function buildSessionSnapshot({ sessionName, coordinationDir, panes }) {
const workerSnapshots = loadWorkerSnapshots(coordinationDir);
const paneMap = new Map(panes.map(pane => [pane.title, pane]));
const workers = workerSnapshots.map(worker => ({
...worker,
pane: paneMap.get(worker.workerSlug) || null
}));
return {
sessionName,
coordinationDir,
sessionActive: panes.length > 0,
paneCount: panes.length,
workerCount: workers.length,
workerStates: summarizeWorkerStates(workers),
panes,
workers
};
}
function resolveSnapshotTarget(targetPath, cwd = process.cwd()) {
const absoluteTarget = path.resolve(cwd, targetPath);
if (fs.existsSync(absoluteTarget) && fs.statSync(absoluteTarget).isFile()) {
const config = JSON.parse(fs.readFileSync(absoluteTarget, 'utf8'));
const repoRoot = path.resolve(config.repoRoot || cwd);
const coordinationRoot = path.resolve(
config.coordinationRoot || path.join(repoRoot, '.orchestration')
);
return {
sessionName: config.sessionName,
coordinationDir: path.join(coordinationRoot, config.sessionName),
repoRoot,
targetType: 'plan'
};
}
return {
sessionName: targetPath,
coordinationDir: path.join(cwd, '.claude', 'orchestration', targetPath),
repoRoot: cwd,
targetType: 'session'
};
}
function collectSessionSnapshot(targetPath, cwd = process.cwd()) {
const target = resolveSnapshotTarget(targetPath, cwd);
const panes = listTmuxPanes(target.sessionName);
const snapshot = buildSessionSnapshot({
sessionName: target.sessionName,
coordinationDir: target.coordinationDir,
panes
});
return {
...snapshot,
repoRoot: target.repoRoot,
targetType: target.targetType
};
}
module.exports = {
buildSessionSnapshot,
collectSessionSnapshot,
listTmuxPanes,
loadWorkerSnapshots,
normalizeText: stripCodeTicks,
parseWorkerHandoff,
parseWorkerStatus,
parseWorkerTask,
resolveSnapshotTarget
};

View File

@@ -0,0 +1,119 @@
/**
* Package Manager Detection and Selection.
* Supports: npm, pnpm, yarn, bun.
*/
/** Supported package manager names */
export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' | 'bun';
/** Configuration for a single package manager */
export interface PackageManagerConfig {
name: PackageManagerName;
/** Lock file name (e.g., "package-lock.json", "pnpm-lock.yaml") */
lockFile: string;
/** Install command (e.g., "npm install") */
installCmd: string;
/** Run script command prefix (e.g., "npm run", "pnpm") */
runCmd: string;
/** Execute binary command (e.g., "npx", "pnpm dlx") */
execCmd: string;
/** Test command (e.g., "npm test") */
testCmd: string;
/** Build command (e.g., "npm run build") */
buildCmd: string;
/** Dev server command (e.g., "npm run dev") */
devCmd: string;
}
/** How the package manager was detected */
export type DetectionSource =
| 'environment'
| 'project-config'
| 'package.json'
| 'lock-file'
| 'global-config'
| 'default';
/** Result from getPackageManager() */
export interface PackageManagerResult {
name: PackageManagerName;
config: PackageManagerConfig;
source: DetectionSource;
}
/** Map of all supported package managers keyed by name */
export const PACKAGE_MANAGERS: Record<PackageManagerName, PackageManagerConfig>;
/** Priority order for lock file detection */
export const DETECTION_PRIORITY: PackageManagerName[];
export interface GetPackageManagerOptions {
/** Project directory to detect from (default: process.cwd()) */
projectDir?: string;
}
/**
* Get the package manager to use for the current project.
*
* Detection priority:
* 1. CLAUDE_PACKAGE_MANAGER environment variable
* 2. Project-specific config (.claude/package-manager.json)
* 3. package.json `packageManager` field
* 4. Lock file detection
* 5. Global user preference (~/.claude/package-manager.json)
* 6. Default to npm (no child processes spawned)
*/
export function getPackageManager(options?: GetPackageManagerOptions): PackageManagerResult;
/**
* Set the user's globally preferred package manager.
* Saves to ~/.claude/package-manager.json.
* @throws If pmName is not a known package manager or if save fails
*/
export function setPreferredPackageManager(pmName: PackageManagerName): { packageManager: string; setAt: string };
/**
* Set a project-specific preferred package manager.
* Saves to <projectDir>/.claude/package-manager.json.
* @throws If pmName is not a known package manager
*/
export function setProjectPackageManager(pmName: PackageManagerName, projectDir?: string): { packageManager: string; setAt: string };
/**
* Get package managers installed on the system.
* WARNING: Spawns child processes for each PM check.
* Do NOT call during session startup hooks.
*/
export function getAvailablePackageManagers(): PackageManagerName[];
/** Detect package manager from lock file in the given directory */
export function detectFromLockFile(projectDir?: string): PackageManagerName | null;
/** Detect package manager from package.json `packageManager` field */
export function detectFromPackageJson(projectDir?: string): PackageManagerName | null;
/**
* Get the full command string to run a script.
* @param script - Script name: "install", "test", "build", "dev", or custom
*/
export function getRunCommand(script: string, options?: GetPackageManagerOptions): string;
/**
* Get the full command string to execute a package binary.
* @param binary - Binary name (e.g., "prettier", "eslint")
* @param args - Arguments to pass to the binary
*/
export function getExecCommand(binary: string, args?: string, options?: GetPackageManagerOptions): string;
/**
* Get a message prompting the user to configure their package manager.
* Does NOT spawn child processes.
*/
export function getSelectionPrompt(): string;
/**
* Generate a regex pattern string that matches commands for all package managers.
* @param action - Action like "dev", "install", "test", "build", or custom
* @returns Parenthesized alternation regex string, e.g., "(npm run dev|pnpm( run)? dev|...)"
*/
export function getCommandPattern(action: string): string;

View File

@@ -0,0 +1,431 @@
/**
* Package Manager Detection and Selection
* Automatically detects the preferred package manager or lets user choose
*
* Supports: npm, pnpm, yarn, bun
*/
const fs = require('fs');
const path = require('path');
const { commandExists, getClaudeDir, readFile, writeFile } = require('./utils');
// Package manager definitions
const PACKAGE_MANAGERS = {
npm: {
name: 'npm',
lockFile: 'package-lock.json',
installCmd: 'npm install',
runCmd: 'npm run',
execCmd: 'npx',
testCmd: 'npm test',
buildCmd: 'npm run build',
devCmd: 'npm run dev'
},
pnpm: {
name: 'pnpm',
lockFile: 'pnpm-lock.yaml',
installCmd: 'pnpm install',
runCmd: 'pnpm',
execCmd: 'pnpm dlx',
testCmd: 'pnpm test',
buildCmd: 'pnpm build',
devCmd: 'pnpm dev'
},
yarn: {
name: 'yarn',
lockFile: 'yarn.lock',
installCmd: 'yarn',
runCmd: 'yarn',
execCmd: 'yarn dlx',
testCmd: 'yarn test',
buildCmd: 'yarn build',
devCmd: 'yarn dev'
},
bun: {
name: 'bun',
lockFile: 'bun.lockb',
installCmd: 'bun install',
runCmd: 'bun run',
execCmd: 'bunx',
testCmd: 'bun test',
buildCmd: 'bun run build',
devCmd: 'bun run dev'
}
};
// Priority order for detection
const DETECTION_PRIORITY = ['pnpm', 'bun', 'yarn', 'npm'];
// Config file path
function getConfigPath() {
return path.join(getClaudeDir(), 'package-manager.json');
}
/**
* Load saved package manager configuration
*/
function loadConfig() {
const configPath = getConfigPath();
const content = readFile(configPath);
if (content) {
try {
return JSON.parse(content);
} catch {
return null;
}
}
return null;
}
/**
* Save package manager configuration
*/
function saveConfig(config) {
const configPath = getConfigPath();
writeFile(configPath, JSON.stringify(config, null, 2));
}
/**
* Detect package manager from lock file in project directory
*/
function detectFromLockFile(projectDir = process.cwd()) {
for (const pmName of DETECTION_PRIORITY) {
const pm = PACKAGE_MANAGERS[pmName];
const lockFilePath = path.join(projectDir, pm.lockFile);
if (fs.existsSync(lockFilePath)) {
return pmName;
}
}
return null;
}
/**
* Detect package manager from package.json packageManager field
*/
function detectFromPackageJson(projectDir = process.cwd()) {
const packageJsonPath = path.join(projectDir, 'package.json');
const content = readFile(packageJsonPath);
if (content) {
try {
const pkg = JSON.parse(content);
if (pkg.packageManager) {
// Format: "pnpm@8.6.0" or just "pnpm"
const pmName = pkg.packageManager.split('@')[0];
if (PACKAGE_MANAGERS[pmName]) {
return pmName;
}
}
} catch {
// Invalid package.json
}
}
return null;
}
/**
* Get available package managers (installed on system)
*
* WARNING: This spawns child processes (where.exe on Windows, which on Unix)
* for each package manager. Do NOT call this during session startup hooks —
* it can exceed Bun's spawn limit on Windows and freeze the plugin.
* Use detectFromLockFile() or detectFromPackageJson() for hot paths.
*/
function getAvailablePackageManagers() {
const available = [];
for (const pmName of Object.keys(PACKAGE_MANAGERS)) {
if (commandExists(pmName)) {
available.push(pmName);
}
}
return available;
}
/**
* Get the package manager to use for current project
*
* Detection priority:
* 1. Environment variable CLAUDE_PACKAGE_MANAGER
* 2. Project-specific config (in .claude/package-manager.json)
* 3. package.json packageManager field
* 4. Lock file detection
* 5. Global user preference (in ~/.claude/package-manager.json)
* 6. Default to npm (no child processes spawned)
*
* @param {object} options - Options
* @param {string} options.projectDir - Project directory to detect from (default: cwd)
* @returns {object} - { name, config, source }
*/
function getPackageManager(options = {}) {
const { projectDir = process.cwd() } = options;
// 1. Check environment variable
const envPm = process.env.CLAUDE_PACKAGE_MANAGER;
if (envPm && PACKAGE_MANAGERS[envPm]) {
return {
name: envPm,
config: PACKAGE_MANAGERS[envPm],
source: 'environment'
};
}
// 2. Check project-specific config
const projectConfigPath = path.join(projectDir, '.claude', 'package-manager.json');
const projectConfig = readFile(projectConfigPath);
if (projectConfig) {
try {
const config = JSON.parse(projectConfig);
if (config.packageManager && PACKAGE_MANAGERS[config.packageManager]) {
return {
name: config.packageManager,
config: PACKAGE_MANAGERS[config.packageManager],
source: 'project-config'
};
}
} catch {
// Invalid config
}
}
// 3. Check package.json packageManager field
const fromPackageJson = detectFromPackageJson(projectDir);
if (fromPackageJson) {
return {
name: fromPackageJson,
config: PACKAGE_MANAGERS[fromPackageJson],
source: 'package.json'
};
}
// 4. Check lock file
const fromLockFile = detectFromLockFile(projectDir);
if (fromLockFile) {
return {
name: fromLockFile,
config: PACKAGE_MANAGERS[fromLockFile],
source: 'lock-file'
};
}
// 5. Check global user preference
const globalConfig = loadConfig();
if (globalConfig && globalConfig.packageManager && PACKAGE_MANAGERS[globalConfig.packageManager]) {
return {
name: globalConfig.packageManager,
config: PACKAGE_MANAGERS[globalConfig.packageManager],
source: 'global-config'
};
}
// 6. Default to npm (always available with Node.js)
// NOTE: Previously this called getAvailablePackageManagers() which spawns
// child processes (where.exe/which) for each PM. This caused plugin freezes
// on Windows (see #162) because session-start hooks run during Bun init,
// and the spawned processes exceed Bun's spawn limit.
// Steps 1-5 already cover all config-based and file-based detection.
// If none matched, npm is the safe default.
return {
name: 'npm',
config: PACKAGE_MANAGERS.npm,
source: 'default'
};
}
/**
* Set user's preferred package manager (global)
*/
function setPreferredPackageManager(pmName) {
if (!PACKAGE_MANAGERS[pmName]) {
throw new Error(`Unknown package manager: ${pmName}`);
}
const config = loadConfig() || {};
config.packageManager = pmName;
config.setAt = new Date().toISOString();
try {
saveConfig(config);
} catch (err) {
throw new Error(`Failed to save package manager preference: ${err.message}`);
}
return config;
}
/**
* Set project's preferred package manager
*/
function setProjectPackageManager(pmName, projectDir = process.cwd()) {
if (!PACKAGE_MANAGERS[pmName]) {
throw new Error(`Unknown package manager: ${pmName}`);
}
const configDir = path.join(projectDir, '.claude');
const configPath = path.join(configDir, 'package-manager.json');
const config = {
packageManager: pmName,
setAt: new Date().toISOString()
};
try {
writeFile(configPath, JSON.stringify(config, null, 2));
} catch (err) {
throw new Error(`Failed to save package manager config to ${configPath}: ${err.message}`);
}
return config;
}
// Allowed characters in script/binary names: alphanumeric, dash, underscore, dot, slash, @
// This prevents shell metacharacter injection while allowing scoped packages (e.g., @scope/pkg)
const SAFE_NAME_REGEX = /^[@a-zA-Z0-9_./-]+$/;
/**
* Get the command to run a script
* @param {string} script - Script name (e.g., "dev", "build", "test")
* @param {object} options - { projectDir }
* @throws {Error} If script name contains unsafe characters
*/
function getRunCommand(script, options = {}) {
if (!script || typeof script !== 'string') {
throw new Error('Script name must be a non-empty string');
}
if (!SAFE_NAME_REGEX.test(script)) {
throw new Error(`Script name contains unsafe characters: ${script}`);
}
const pm = getPackageManager(options);
switch (script) {
case 'install':
return pm.config.installCmd;
case 'test':
return pm.config.testCmd;
case 'build':
return pm.config.buildCmd;
case 'dev':
return pm.config.devCmd;
default:
return `${pm.config.runCmd} ${script}`;
}
}
// Allowed characters in arguments: alphanumeric, whitespace, dashes, dots, slashes,
// equals, colons, commas, quotes, @. Rejects shell metacharacters like ; | & ` $ ( ) { } < > !
const SAFE_ARGS_REGEX = /^[@a-zA-Z0-9\s_./:=,'"*+-]+$/;
/**
* Get the command to execute a package binary
* @param {string} binary - Binary name (e.g., "prettier", "eslint")
* @param {string} args - Arguments to pass
* @throws {Error} If binary name or args contain unsafe characters
*/
function getExecCommand(binary, args = '', options = {}) {
if (!binary || typeof binary !== 'string') {
throw new Error('Binary name must be a non-empty string');
}
if (!SAFE_NAME_REGEX.test(binary)) {
throw new Error(`Binary name contains unsafe characters: ${binary}`);
}
if (args && typeof args === 'string' && !SAFE_ARGS_REGEX.test(args)) {
throw new Error(`Arguments contain unsafe characters: ${args}`);
}
const pm = getPackageManager(options);
return `${pm.config.execCmd} ${binary}${args ? ' ' + args : ''}`;
}
/**
* Interactive prompt for package manager selection
* Returns a message for Claude to show to user
*
* NOTE: Does NOT spawn child processes to check availability.
* Lists all supported PMs and shows how to configure preference.
*/
function getSelectionPrompt() {
let message = '[PackageManager] No package manager preference detected.\n';
message += 'Supported package managers: ' + Object.keys(PACKAGE_MANAGERS).join(', ') + '\n';
message += '\nTo set your preferred package manager:\n';
message += ' - Global: Set CLAUDE_PACKAGE_MANAGER environment variable\n';
message += ' - Or add to ~/.claude/package-manager.json: {"packageManager": "pnpm"}\n';
message += ' - Or add to package.json: {"packageManager": "pnpm@8"}\n';
message += ' - Or add a lock file to your project (e.g., pnpm-lock.yaml)\n';
return message;
}
// Escape regex metacharacters in a string before interpolating into a pattern
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Generate a regex pattern that matches commands for all package managers
* @param {string} action - Action pattern (e.g., "run dev", "install", "test")
*/
function getCommandPattern(action) {
const patterns = [];
// Trim spaces from action to handle leading/trailing whitespace gracefully
const trimmedAction = action.trim();
if (trimmedAction === 'dev') {
patterns.push(
'npm run dev',
'pnpm( run)? dev',
'yarn dev',
'bun run dev'
);
} else if (trimmedAction === 'install') {
patterns.push(
'npm install',
'pnpm install',
'yarn( install)?',
'bun install'
);
} else if (trimmedAction === 'test') {
patterns.push(
'npm test',
'pnpm test',
'yarn test',
'bun test'
);
} else if (trimmedAction === 'build') {
patterns.push(
'npm run build',
'pnpm( run)? build',
'yarn build',
'bun run build'
);
} else {
// Generic run command — escape regex metacharacters in action
const escaped = escapeRegex(trimmedAction);
patterns.push(
`npm run ${escaped}`,
`pnpm( run)? ${escaped}`,
`yarn ${escaped}`,
`bun run ${escaped}`
);
}
return `(${patterns.join('|')})`;
}
module.exports = {
PACKAGE_MANAGERS,
DETECTION_PRIORITY,
getPackageManager,
setPreferredPackageManager,
setProjectPackageManager,
getAvailablePackageManagers,
detectFromLockFile,
detectFromPackageJson,
getRunCommand,
getExecCommand,
getSelectionPrompt,
getCommandPattern
};

View File

@@ -0,0 +1,428 @@
/**
* Project type and framework detection
*
* Cross-platform (Windows, macOS, Linux) project type detection
* by inspecting files in the working directory.
*
* Resolves: https://github.com/affaan-m/everything-claude-code/issues/293
*/
const fs = require('fs');
const path = require('path');
/**
* Language detection rules.
* Each rule checks for marker files or glob patterns in the project root.
*/
const LANGUAGE_RULES = [
{
type: 'python',
markers: ['requirements.txt', 'pyproject.toml', 'setup.py', 'setup.cfg', 'Pipfile', 'poetry.lock'],
extensions: ['.py']
},
{
type: 'typescript',
markers: ['tsconfig.json', 'tsconfig.build.json'],
extensions: ['.ts', '.tsx']
},
{
type: 'javascript',
markers: ['package.json', 'jsconfig.json'],
extensions: ['.js', '.jsx', '.mjs']
},
{
type: 'golang',
markers: ['go.mod', 'go.sum'],
extensions: ['.go']
},
{
type: 'rust',
markers: ['Cargo.toml', 'Cargo.lock'],
extensions: ['.rs']
},
{
type: 'ruby',
markers: ['Gemfile', 'Gemfile.lock', 'Rakefile'],
extensions: ['.rb']
},
{
type: 'java',
markers: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
extensions: ['.java']
},
{
type: 'csharp',
markers: [],
extensions: ['.cs', '.csproj', '.sln']
},
{
type: 'swift',
markers: ['Package.swift'],
extensions: ['.swift']
},
{
type: 'kotlin',
markers: [],
extensions: ['.kt', '.kts']
},
{
type: 'elixir',
markers: ['mix.exs'],
extensions: ['.ex', '.exs']
},
{
type: 'php',
markers: ['composer.json', 'composer.lock'],
extensions: ['.php']
}
];
/**
* Framework detection rules.
* Checked after language detection for more specific identification.
*/
const FRAMEWORK_RULES = [
// Python frameworks
{ framework: 'django', language: 'python', markers: ['manage.py'], packageKeys: ['django'] },
{ framework: 'fastapi', language: 'python', markers: [], packageKeys: ['fastapi'] },
{ framework: 'flask', language: 'python', markers: [], packageKeys: ['flask'] },
// JavaScript/TypeScript frameworks
{ framework: 'nextjs', language: 'typescript', markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'], packageKeys: ['next'] },
{ framework: 'react', language: 'typescript', markers: [], packageKeys: ['react'] },
{ framework: 'vue', language: 'typescript', markers: ['vue.config.js'], packageKeys: ['vue'] },
{ framework: 'angular', language: 'typescript', markers: ['angular.json'], packageKeys: ['@angular/core'] },
{ framework: 'svelte', language: 'typescript', markers: ['svelte.config.js'], packageKeys: ['svelte'] },
{ framework: 'express', language: 'javascript', markers: [], packageKeys: ['express'] },
{ framework: 'nestjs', language: 'typescript', markers: ['nest-cli.json'], packageKeys: ['@nestjs/core'] },
{ framework: 'remix', language: 'typescript', markers: [], packageKeys: ['@remix-run/node', '@remix-run/react'] },
{ framework: 'astro', language: 'typescript', markers: ['astro.config.mjs', 'astro.config.ts'], packageKeys: ['astro'] },
{ framework: 'nuxt', language: 'typescript', markers: ['nuxt.config.js', 'nuxt.config.ts'], packageKeys: ['nuxt'] },
{ framework: 'electron', language: 'typescript', markers: [], packageKeys: ['electron'] },
// Ruby frameworks
{ framework: 'rails', language: 'ruby', markers: ['config/routes.rb', 'bin/rails'], packageKeys: [] },
// Go frameworks
{ framework: 'gin', language: 'golang', markers: [], packageKeys: ['github.com/gin-gonic/gin'] },
{ framework: 'echo', language: 'golang', markers: [], packageKeys: ['github.com/labstack/echo'] },
// Rust frameworks
{ framework: 'actix', language: 'rust', markers: [], packageKeys: ['actix-web'] },
{ framework: 'axum', language: 'rust', markers: [], packageKeys: ['axum'] },
// Java frameworks
{ framework: 'spring', language: 'java', markers: [], packageKeys: ['spring-boot', 'org.springframework'] },
// PHP frameworks
{ framework: 'laravel', language: 'php', markers: ['artisan'], packageKeys: ['laravel/framework'] },
{ framework: 'symfony', language: 'php', markers: ['symfony.lock'], packageKeys: ['symfony/framework-bundle'] },
// Elixir frameworks
{ framework: 'phoenix', language: 'elixir', markers: [], packageKeys: ['phoenix'] }
];
/**
* Check if a file exists relative to the project directory
* @param {string} projectDir - Project root directory
* @param {string} filePath - Relative file path
* @returns {boolean}
*/
function fileExists(projectDir, filePath) {
try {
return fs.existsSync(path.join(projectDir, filePath));
} catch {
return false;
}
}
/**
* Check if any file with given extension exists in the project root (non-recursive, top-level only)
* @param {string} projectDir - Project root directory
* @param {string[]} extensions - File extensions to check
* @returns {boolean}
*/
function hasFileWithExtension(projectDir, extensions) {
try {
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
return entries.some(entry => {
if (!entry.isFile()) return false;
const ext = path.extname(entry.name);
return extensions.includes(ext);
});
} catch {
return false;
}
}
/**
* Read and parse package.json dependencies
* @param {string} projectDir - Project root directory
* @returns {string[]} Array of dependency names
*/
function getPackageJsonDeps(projectDir) {
try {
const pkgPath = path.join(projectDir, 'package.json');
if (!fs.existsSync(pkgPath)) return [];
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];
} catch {
return [];
}
}
/**
* Read requirements.txt or pyproject.toml for Python package names
* @param {string} projectDir - Project root directory
* @returns {string[]} Array of dependency names (lowercase)
*/
function getPythonDeps(projectDir) {
const deps = [];
// requirements.txt
try {
const reqPath = path.join(projectDir, 'requirements.txt');
if (fs.existsSync(reqPath)) {
const content = fs.readFileSync(reqPath, 'utf8');
content.split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('-')) {
const name = trimmed
.split(/[>=<![;]/)[0]
.trim()
.toLowerCase();
if (name) deps.push(name);
}
});
}
} catch {
/* ignore */
}
// pyproject.toml — simple extraction of dependency names
try {
const tomlPath = path.join(projectDir, 'pyproject.toml');
if (fs.existsSync(tomlPath)) {
const content = fs.readFileSync(tomlPath, 'utf8');
const depMatches = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
if (depMatches) {
const block = depMatches[1];
block.match(/"([^"]+)"/g)?.forEach(m => {
const name = m
.replace(/"/g, '')
.split(/[>=<![;]/)[0]
.trim()
.toLowerCase();
if (name) deps.push(name);
});
}
}
} catch {
/* ignore */
}
return deps;
}
/**
* Read go.mod for Go module dependencies
* @param {string} projectDir - Project root directory
* @returns {string[]} Array of module paths
*/
function getGoDeps(projectDir) {
try {
const modPath = path.join(projectDir, 'go.mod');
if (!fs.existsSync(modPath)) return [];
const content = fs.readFileSync(modPath, 'utf8');
const deps = [];
const requireBlock = content.match(/require\s*\(([\s\S]*?)\)/);
if (requireBlock) {
requireBlock[1].split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('//')) {
const parts = trimmed.split(/\s+/);
if (parts[0]) deps.push(parts[0]);
}
});
}
return deps;
} catch {
return [];
}
}
/**
* Read Cargo.toml for Rust crate dependencies
* @param {string} projectDir - Project root directory
* @returns {string[]} Array of crate names
*/
function getRustDeps(projectDir) {
try {
const cargoPath = path.join(projectDir, 'Cargo.toml');
if (!fs.existsSync(cargoPath)) return [];
const content = fs.readFileSync(cargoPath, 'utf8');
const deps = [];
// Match [dependencies] and [dev-dependencies] sections
const sections = content.match(/\[(dev-)?dependencies\]([\s\S]*?)(?=\n\[|$)/g);
if (sections) {
sections.forEach(section => {
section.split('\n').forEach(line => {
const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/);
if (match && !line.startsWith('[')) {
deps.push(match[1]);
}
});
});
}
return deps;
} catch {
return [];
}
}
/**
* Read composer.json for PHP package dependencies
* @param {string} projectDir - Project root directory
* @returns {string[]} Array of package names
*/
function getComposerDeps(projectDir) {
try {
const composerPath = path.join(projectDir, 'composer.json');
if (!fs.existsSync(composerPath)) return [];
const composer = JSON.parse(fs.readFileSync(composerPath, 'utf8'));
return [...Object.keys(composer.require || {}), ...Object.keys(composer['require-dev'] || {})];
} catch {
return [];
}
}
/**
* Read mix.exs for Elixir dependencies (simple pattern match)
* @param {string} projectDir - Project root directory
* @returns {string[]} Array of dependency atom names
*/
function getElixirDeps(projectDir) {
try {
const mixPath = path.join(projectDir, 'mix.exs');
if (!fs.existsSync(mixPath)) return [];
const content = fs.readFileSync(mixPath, 'utf8');
const deps = [];
const matches = content.match(/\{:(\w+)/g);
if (matches) {
matches.forEach(m => deps.push(m.replace('{:', '')));
}
return deps;
} catch {
return [];
}
}
/**
* Detect project languages and frameworks
* @param {string} [projectDir] - Project directory (defaults to cwd)
* @returns {{ languages: string[], frameworks: string[], primary: string, projectDir: string }}
*/
function detectProjectType(projectDir) {
projectDir = projectDir || process.cwd();
const languages = [];
const frameworks = [];
// Step 1: Detect languages
for (const rule of LANGUAGE_RULES) {
const hasMarker = rule.markers.some(m => fileExists(projectDir, m));
const hasExt = rule.extensions.length > 0 && hasFileWithExtension(projectDir, rule.extensions);
if (hasMarker || hasExt) {
languages.push(rule.type);
}
}
// Deduplicate: if both typescript and javascript detected, keep typescript
if (languages.includes('typescript') && languages.includes('javascript')) {
const idx = languages.indexOf('javascript');
if (idx !== -1) languages.splice(idx, 1);
}
// Step 2: Detect frameworks based on markers and dependencies
const npmDeps = getPackageJsonDeps(projectDir);
const pyDeps = getPythonDeps(projectDir);
const goDeps = getGoDeps(projectDir);
const rustDeps = getRustDeps(projectDir);
const composerDeps = getComposerDeps(projectDir);
const elixirDeps = getElixirDeps(projectDir);
for (const rule of FRAMEWORK_RULES) {
// Check marker files
const hasMarker = rule.markers.some(m => fileExists(projectDir, m));
// Check package dependencies
let hasDep = false;
if (rule.packageKeys.length > 0) {
let depList = [];
switch (rule.language) {
case 'python':
depList = pyDeps;
break;
case 'typescript':
case 'javascript':
depList = npmDeps;
break;
case 'golang':
depList = goDeps;
break;
case 'rust':
depList = rustDeps;
break;
case 'php':
depList = composerDeps;
break;
case 'elixir':
depList = elixirDeps;
break;
}
hasDep = rule.packageKeys.some(key => depList.some(dep => dep.toLowerCase().includes(key.toLowerCase())));
}
if (hasMarker || hasDep) {
frameworks.push(rule.framework);
}
}
// Step 3: Determine primary type
let primary = 'unknown';
if (frameworks.length > 0) {
primary = frameworks[0];
} else if (languages.length > 0) {
primary = languages[0];
}
// Determine if fullstack (both frontend and backend languages)
const frontendSignals = ['react', 'vue', 'angular', 'svelte', 'nextjs', 'nuxt', 'astro', 'remix'];
const backendSignals = ['django', 'fastapi', 'flask', 'express', 'nestjs', 'rails', 'spring', 'laravel', 'phoenix', 'gin', 'echo', 'actix', 'axum'];
const hasFrontend = frameworks.some(f => frontendSignals.includes(f));
const hasBackend = frameworks.some(f => backendSignals.includes(f));
if (hasFrontend && hasBackend) {
primary = 'fullstack';
}
return {
languages,
frameworks,
primary,
projectDir
};
}
module.exports = {
detectProjectType,
LANGUAGE_RULES,
FRAMEWORK_RULES,
// Exported for testing
getPackageJsonDeps,
getPythonDeps,
getGoDeps,
getRustDeps,
getComposerDeps,
getElixirDeps
};

View File

@@ -0,0 +1,104 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* Resolve the ECC source root directory.
*
* Tries, in order:
* 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks, or by user)
* 2. Standard install location (~/.claude/) — when scripts exist there
* 3. Exact legacy plugin roots under ~/.claude/plugins/
* 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/everything-claude-code/
* 5. Fallback to ~/.claude/ (original behaviour)
*
* @param {object} [options]
* @param {string} [options.homeDir] Override home directory (for testing)
* @param {string} [options.envRoot] Override CLAUDE_PLUGIN_ROOT (for testing)
* @param {string} [options.probe] Relative path used to verify a candidate root
* contains ECC scripts. Default: 'scripts/lib/utils.js'
* @returns {string} Resolved ECC root path
*/
function resolveEccRoot(options = {}) {
const envRoot = options.envRoot !== undefined
? options.envRoot
: (process.env.CLAUDE_PLUGIN_ROOT || '');
if (envRoot && envRoot.trim()) {
return envRoot.trim();
}
const homeDir = options.homeDir || os.homedir();
const claudeDir = path.join(homeDir, '.claude');
const probe = options.probe || path.join('scripts', 'lib', 'utils.js');
// Standard install — files are copied directly into ~/.claude/
if (fs.existsSync(path.join(claudeDir, probe))) {
return claudeDir;
}
// Exact legacy plugin install locations. These preserve backwards
// compatibility without scanning arbitrary plugin trees.
const legacyPluginRoots = [
path.join(claudeDir, 'plugins', 'everything-claude-code'),
path.join(claudeDir, 'plugins', 'everything-claude-code@everything-claude-code'),
path.join(claudeDir, 'plugins', 'marketplace', 'everything-claude-code')
];
for (const candidate of legacyPluginRoots) {
if (fs.existsSync(path.join(candidate, probe))) {
return candidate;
}
}
// Plugin cache — Claude Code stores marketplace plugins under
// ~/.claude/plugins/cache/<plugin-name>/<org>/<version>/
try {
const cacheBase = path.join(claudeDir, 'plugins', 'cache', 'everything-claude-code');
const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true });
for (const orgEntry of orgDirs) {
if (!orgEntry.isDirectory()) continue;
const orgPath = path.join(cacheBase, orgEntry.name);
let versionDirs;
try {
versionDirs = fs.readdirSync(orgPath, { withFileTypes: true });
} catch {
continue;
}
for (const verEntry of versionDirs) {
if (!verEntry.isDirectory()) continue;
const candidate = path.join(orgPath, verEntry.name);
if (fs.existsSync(path.join(candidate, probe))) {
return candidate;
}
}
}
} catch {
// Plugin cache doesn't exist or isn't readable — continue to fallback
}
return claudeDir;
}
/**
* Compact inline version for embedding in command .md code blocks.
*
* This is the minified form of resolveEccRoot() suitable for use in
* node -e "..." scripts where require() is not available before the
* root is known.
*
* Usage in commands:
* const _r = <paste INLINE_RESOLVE>;
* const sm = require(_r + '/scripts/lib/session-manager');
*/
const INLINE_RESOLVE = `(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var l of [p.join(d,'plugins','everything-claude-code'),p.join(d,'plugins','everything-claude-code@everything-claude-code'),p.join(d,'plugins','marketplace','everything-claude-code')])if(f.existsSync(p.join(l,q)))return l;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}catch(x){}return d})()`;
module.exports = {
resolveEccRoot,
INLINE_RESOLVE,
};

View File

@@ -0,0 +1,185 @@
/**
* Shared formatter resolution utilities with caching.
*
* Extracts project-root discovery, formatter detection, and binary
* resolution into a single module so that post-edit-format.js and
* quality-gate.js avoid duplicating work and filesystem lookups.
*/
'use strict';
const fs = require('fs');
const path = require('path');
// ── Caches (per-process, cleared on next hook invocation) ───────────
const projectRootCache = new Map();
const formatterCache = new Map();
const binCache = new Map();
// ── Config file lists (single source of truth) ─────────────────────
const BIOME_CONFIGS = ['biome.json', 'biome.jsonc'];
const PRETTIER_CONFIGS = [
'.prettierrc',
'.prettierrc.json',
'.prettierrc.js',
'.prettierrc.cjs',
'.prettierrc.mjs',
'.prettierrc.yml',
'.prettierrc.yaml',
'.prettierrc.toml',
'prettier.config.js',
'prettier.config.cjs',
'prettier.config.mjs'
];
const PROJECT_ROOT_MARKERS = ['package.json', ...BIOME_CONFIGS, ...PRETTIER_CONFIGS];
// ── Windows .cmd shim mapping ───────────────────────────────────────
const WIN_CMD_SHIMS = { npx: 'npx.cmd', pnpm: 'pnpm.cmd', yarn: 'yarn.cmd', bunx: 'bunx.cmd' };
// ── Formatter → package name mapping ────────────────────────────────
const FORMATTER_PACKAGES = {
biome: { binName: 'biome', pkgName: '@biomejs/biome' },
prettier: { binName: 'prettier', pkgName: 'prettier' }
};
// ── Public helpers ──────────────────────────────────────────────────
/**
* Walk up from `startDir` until a directory containing a known project
* root marker (package.json or formatter config) is found.
* Returns `startDir` as fallback when no marker exists above it.
*
* @param {string} startDir - Absolute directory path to start from
* @returns {string} Absolute path to the project root
*/
function findProjectRoot(startDir) {
if (projectRootCache.has(startDir)) return projectRootCache.get(startDir);
let dir = startDir;
while (dir !== path.dirname(dir)) {
for (const marker of PROJECT_ROOT_MARKERS) {
if (fs.existsSync(path.join(dir, marker))) {
projectRootCache.set(startDir, dir);
return dir;
}
}
dir = path.dirname(dir);
}
projectRootCache.set(startDir, startDir);
return startDir;
}
/**
* Detect the formatter configured in the project.
* Biome takes priority over Prettier.
*
* @param {string} projectRoot - Absolute path to the project root
* @returns {'biome' | 'prettier' | null}
*/
function detectFormatter(projectRoot) {
if (formatterCache.has(projectRoot)) return formatterCache.get(projectRoot);
for (const cfg of BIOME_CONFIGS) {
if (fs.existsSync(path.join(projectRoot, cfg))) {
formatterCache.set(projectRoot, 'biome');
return 'biome';
}
}
// Check package.json "prettier" key before config files
try {
const pkgPath = path.join(projectRoot, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if ('prettier' in pkg) {
formatterCache.set(projectRoot, 'prettier');
return 'prettier';
}
}
} catch {
// Malformed package.json — continue to file-based detection
}
for (const cfg of PRETTIER_CONFIGS) {
if (fs.existsSync(path.join(projectRoot, cfg))) {
formatterCache.set(projectRoot, 'prettier');
return 'prettier';
}
}
formatterCache.set(projectRoot, null);
return null;
}
/**
* Resolve the runner binary and prefix args for the configured package
* manager (respects CLAUDE_PACKAGE_MANAGER env and project config).
*
* @param {string} projectRoot - Absolute path to the project root
* @returns {{ bin: string, prefix: string[] }}
*/
function getRunnerFromPackageManager(projectRoot) {
const isWin = process.platform === 'win32';
const { getPackageManager } = require('./package-manager');
const pm = getPackageManager({ projectDir: projectRoot });
const execCmd = pm?.config?.execCmd || 'npx';
const [rawBin = 'npx', ...prefix] = execCmd.split(/\s+/).filter(Boolean);
const bin = isWin ? WIN_CMD_SHIMS[rawBin] || rawBin : rawBin;
return { bin, prefix };
}
/**
* Resolve the formatter binary, preferring the local node_modules/.bin
* installation over the package manager exec command to avoid
* package-resolution overhead.
*
* @param {string} projectRoot - Absolute path to the project root
* @param {'biome' | 'prettier'} formatter - Detected formatter name
* @returns {{ bin: string, prefix: string[] } | null}
* `bin` executable path (absolute local path or runner binary)
* `prefix` extra args to prepend (e.g. ['@biomejs/biome'] when using npx)
*/
function resolveFormatterBin(projectRoot, formatter) {
const cacheKey = `${projectRoot}:${formatter}`;
if (binCache.has(cacheKey)) return binCache.get(cacheKey);
const pkg = FORMATTER_PACKAGES[formatter];
if (!pkg) {
binCache.set(cacheKey, null);
return null;
}
const isWin = process.platform === 'win32';
const localBin = path.join(projectRoot, 'node_modules', '.bin', isWin ? `${pkg.binName}.cmd` : pkg.binName);
if (fs.existsSync(localBin)) {
const result = { bin: localBin, prefix: [] };
binCache.set(cacheKey, result);
return result;
}
const runner = getRunnerFromPackageManager(projectRoot);
const result = { bin: runner.bin, prefix: [...runner.prefix, pkg.pkgName] };
binCache.set(cacheKey, result);
return result;
}
/**
* Clear all caches. Useful for testing.
*/
function clearCaches() {
projectRootCache.clear();
formatterCache.clear();
binCache.clear();
}
module.exports = {
findProjectRoot,
detectFormatter,
resolveFormatterBin,
clearCaches
};

View File

@@ -0,0 +1,531 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const SESSION_SCHEMA_VERSION = 'ecc.session.v1';
const SESSION_RECORDING_SCHEMA_VERSION = 'ecc.session.recording.v1';
const DEFAULT_RECORDING_DIR = path.join(os.tmpdir(), 'ecc-session-recordings');
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function sanitizePathSegment(value) {
return String(value || 'unknown')
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '') || 'unknown';
}
function parseContextSeedPaths(context) {
if (typeof context !== 'string' || context.trim().length === 0) {
return [];
}
return context
.split('\n')
.map(line => line.trim())
.filter(Boolean);
}
function ensureString(value, fieldPath) {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a non-empty string`);
}
}
function ensureOptionalString(value, fieldPath) {
if (value !== null && value !== undefined && typeof value !== 'string') {
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a string or null`);
}
}
function ensureBoolean(value, fieldPath) {
if (typeof value !== 'boolean') {
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a boolean`);
}
}
function ensureArrayOfStrings(value, fieldPath) {
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
throw new Error(`Canonical session snapshot requires ${fieldPath} to be an array of strings`);
}
}
function ensureInteger(value, fieldPath) {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`Canonical session snapshot requires ${fieldPath} to be a non-negative integer`);
}
}
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
function parseUpdatedMs(updated) {
if (typeof updated !== 'string' || updated.length === 0) return null;
const ms = Date.parse(updated);
return Number.isNaN(ms) ? null : ms;
}
function deriveWorkerHealth(rawWorker) {
const state = (rawWorker.status && rawWorker.status.state) || 'unknown';
const completedStates = ['completed', 'succeeded', 'success', 'done'];
const failedStates = ['failed', 'error'];
if (failedStates.includes(state)) return 'degraded';
if (completedStates.includes(state)) return 'healthy';
if (state === 'running' || state === 'active') {
const pane = rawWorker.pane;
if (pane && pane.dead) return 'degraded';
const updatedMs = parseUpdatedMs(rawWorker.status && rawWorker.status.updated);
if (updatedMs === null) return 'stale';
if (Date.now() - updatedMs > STALE_THRESHOLD_MS) return 'stale';
return 'healthy';
}
return 'unknown';
}
function buildAggregates(workers) {
const states = workers.reduce((accumulator, worker) => {
const state = worker.state || 'unknown';
accumulator[state] = (accumulator[state] || 0) + 1;
return accumulator;
}, {});
const healths = workers.reduce((accumulator, worker) => {
const health = worker.health || 'unknown';
accumulator[health] = (accumulator[health] || 0) + 1;
return accumulator;
}, {});
return {
workerCount: workers.length,
states,
healths
};
}
function summarizeRawWorkerStates(snapshot) {
if (isObject(snapshot.workerStates)) {
return snapshot.workerStates;
}
return (snapshot.workers || []).reduce((counts, worker) => {
const state = worker && worker.status && worker.status.state
? worker.status.state
: 'unknown';
counts[state] = (counts[state] || 0) + 1;
return counts;
}, {});
}
function deriveDmuxSessionState(snapshot) {
const workerStates = summarizeRawWorkerStates(snapshot);
const totalWorkers = Number.isInteger(snapshot.workerCount)
? snapshot.workerCount
: Object.values(workerStates).reduce((sum, count) => sum + count, 0);
if (snapshot.sessionActive) {
return 'active';
}
if (totalWorkers === 0) {
return 'missing';
}
const failedCount = (workerStates.failed || 0) + (workerStates.error || 0);
if (failedCount > 0) {
return 'failed';
}
const completedCount = (workerStates.completed || 0)
+ (workerStates.succeeded || 0)
+ (workerStates.success || 0)
+ (workerStates.done || 0);
if (completedCount === totalWorkers) {
return 'completed';
}
return 'idle';
}
function validateCanonicalSnapshot(snapshot) {
if (!isObject(snapshot)) {
throw new Error('Canonical session snapshot must be an object');
}
ensureString(snapshot.schemaVersion, 'schemaVersion');
if (snapshot.schemaVersion !== SESSION_SCHEMA_VERSION) {
throw new Error(`Unsupported canonical session schema version: ${snapshot.schemaVersion}`);
}
ensureString(snapshot.adapterId, 'adapterId');
if (!isObject(snapshot.session)) {
throw new Error('Canonical session snapshot requires session to be an object');
}
ensureString(snapshot.session.id, 'session.id');
ensureString(snapshot.session.kind, 'session.kind');
ensureString(snapshot.session.state, 'session.state');
ensureOptionalString(snapshot.session.repoRoot, 'session.repoRoot');
if (!isObject(snapshot.session.sourceTarget)) {
throw new Error('Canonical session snapshot requires session.sourceTarget to be an object');
}
ensureString(snapshot.session.sourceTarget.type, 'session.sourceTarget.type');
ensureString(snapshot.session.sourceTarget.value, 'session.sourceTarget.value');
if (!Array.isArray(snapshot.workers)) {
throw new Error('Canonical session snapshot requires workers to be an array');
}
snapshot.workers.forEach((worker, index) => {
if (!isObject(worker)) {
throw new Error(`Canonical session snapshot requires workers[${index}] to be an object`);
}
ensureString(worker.id, `workers[${index}].id`);
ensureString(worker.label, `workers[${index}].label`);
ensureString(worker.state, `workers[${index}].state`);
ensureString(worker.health, `workers[${index}].health`);
ensureOptionalString(worker.branch, `workers[${index}].branch`);
ensureOptionalString(worker.worktree, `workers[${index}].worktree`);
if (!isObject(worker.runtime)) {
throw new Error(`Canonical session snapshot requires workers[${index}].runtime to be an object`);
}
ensureString(worker.runtime.kind, `workers[${index}].runtime.kind`);
ensureOptionalString(worker.runtime.command, `workers[${index}].runtime.command`);
ensureBoolean(worker.runtime.active, `workers[${index}].runtime.active`);
ensureBoolean(worker.runtime.dead, `workers[${index}].runtime.dead`);
if (!isObject(worker.intent)) {
throw new Error(`Canonical session snapshot requires workers[${index}].intent to be an object`);
}
ensureString(worker.intent.objective, `workers[${index}].intent.objective`);
ensureArrayOfStrings(worker.intent.seedPaths, `workers[${index}].intent.seedPaths`);
if (!isObject(worker.outputs)) {
throw new Error(`Canonical session snapshot requires workers[${index}].outputs to be an object`);
}
ensureArrayOfStrings(worker.outputs.summary, `workers[${index}].outputs.summary`);
ensureArrayOfStrings(worker.outputs.validation, `workers[${index}].outputs.validation`);
ensureArrayOfStrings(worker.outputs.remainingRisks, `workers[${index}].outputs.remainingRisks`);
if (!isObject(worker.artifacts)) {
throw new Error(`Canonical session snapshot requires workers[${index}].artifacts to be an object`);
}
});
if (!isObject(snapshot.aggregates)) {
throw new Error('Canonical session snapshot requires aggregates to be an object');
}
ensureInteger(snapshot.aggregates.workerCount, 'aggregates.workerCount');
if (snapshot.aggregates.workerCount !== snapshot.workers.length) {
throw new Error('Canonical session snapshot requires aggregates.workerCount to match workers.length');
}
if (!isObject(snapshot.aggregates.states)) {
throw new Error('Canonical session snapshot requires aggregates.states to be an object');
}
if (!isObject(snapshot.aggregates.healths)) {
throw new Error('Canonical session snapshot requires aggregates.healths to be an object');
}
for (const [state, count] of Object.entries(snapshot.aggregates.states)) {
ensureString(state, 'aggregates.states key');
ensureInteger(count, `aggregates.states.${state}`);
}
for (const [health, count] of Object.entries(snapshot.aggregates.healths)) {
ensureString(health, 'aggregates.healths key');
ensureInteger(count, `aggregates.healths.${health}`);
}
return snapshot;
}
function resolveRecordingDir(options = {}) {
if (typeof options.recordingDir === 'string' && options.recordingDir.length > 0) {
return path.resolve(options.recordingDir);
}
if (typeof process.env.ECC_SESSION_RECORDING_DIR === 'string' && process.env.ECC_SESSION_RECORDING_DIR.length > 0) {
return path.resolve(process.env.ECC_SESSION_RECORDING_DIR);
}
return DEFAULT_RECORDING_DIR;
}
function getFallbackSessionRecordingPath(snapshot, options = {}) {
validateCanonicalSnapshot(snapshot);
return path.join(
resolveRecordingDir(options),
sanitizePathSegment(snapshot.adapterId),
`${sanitizePathSegment(snapshot.session.id)}.json`
);
}
function readExistingRecording(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
function writeFallbackSessionRecording(snapshot, options = {}) {
const filePath = getFallbackSessionRecordingPath(snapshot, options);
const recordedAt = new Date().toISOString();
const existing = readExistingRecording(filePath);
const snapshotChanged = !existing
|| JSON.stringify(existing.latest) !== JSON.stringify(snapshot);
const payload = {
schemaVersion: SESSION_RECORDING_SCHEMA_VERSION,
adapterId: snapshot.adapterId,
sessionId: snapshot.session.id,
createdAt: existing && typeof existing.createdAt === 'string'
? existing.createdAt
: recordedAt,
updatedAt: recordedAt,
latest: snapshot,
history: Array.isArray(existing && existing.history)
? (snapshotChanged
? existing.history.concat([{ recordedAt, snapshot }])
: existing.history)
: [{ recordedAt, snapshot }]
};
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
return {
backend: 'json-file',
path: filePath,
recordedAt
};
}
function loadStateStore(options = {}) {
if (options.stateStore) {
return options.stateStore;
}
const loadStateStoreImpl = options.loadStateStoreImpl || (() => require('../state-store'));
try {
return loadStateStoreImpl();
} catch (error) {
const missingRequestedModule = error
&& error.code === 'MODULE_NOT_FOUND'
&& typeof error.message === 'string'
&& error.message.includes('../state-store');
if (missingRequestedModule) {
return null;
}
throw error;
}
}
function resolveStateStoreWriter(stateStore) {
if (!stateStore) {
return null;
}
const candidates = [
{ owner: stateStore, fn: stateStore.persistCanonicalSessionSnapshot },
{ owner: stateStore, fn: stateStore.recordCanonicalSessionSnapshot },
{ owner: stateStore, fn: stateStore.persistSessionSnapshot },
{ owner: stateStore, fn: stateStore.recordSessionSnapshot },
{ owner: stateStore, fn: stateStore.writeSessionSnapshot },
{
owner: stateStore.sessions,
fn: stateStore.sessions && stateStore.sessions.persistCanonicalSessionSnapshot
},
{
owner: stateStore.sessions,
fn: stateStore.sessions && stateStore.sessions.recordCanonicalSessionSnapshot
},
{
owner: stateStore.sessions,
fn: stateStore.sessions && stateStore.sessions.persistSessionSnapshot
},
{
owner: stateStore.sessions,
fn: stateStore.sessions && stateStore.sessions.recordSessionSnapshot
}
];
const writer = candidates.find(candidate => typeof candidate.fn === 'function');
return writer ? writer.fn.bind(writer.owner) : null;
}
function persistCanonicalSnapshot(snapshot, options = {}) {
validateCanonicalSnapshot(snapshot);
if (options.persist === false) {
return {
backend: 'skipped',
path: null,
recordedAt: null
};
}
const stateStore = loadStateStore(options);
const writer = resolveStateStoreWriter(stateStore);
if (stateStore && !writer) {
// The loaded object is a factory module (e.g. has createStateStore but no
// writer methods). Treat it the same as a missing state store and fall
// through to the JSON-file recording path below.
return writeFallbackSessionRecording(snapshot, options);
}
if (writer) {
writer(snapshot, {
adapterId: snapshot.adapterId,
schemaVersion: snapshot.schemaVersion,
sessionId: snapshot.session.id
});
return {
backend: 'state-store',
path: null,
recordedAt: null
};
}
return writeFallbackSessionRecording(snapshot, options);
}
function normalizeDmuxSnapshot(snapshot, sourceTarget) {
const workers = (snapshot.workers || []).map(worker => ({
id: worker.workerSlug,
label: worker.workerSlug,
state: worker.status.state || 'unknown',
health: deriveWorkerHealth(worker),
branch: worker.status.branch || null,
worktree: worker.status.worktree || null,
runtime: {
kind: 'tmux-pane',
command: worker.pane ? worker.pane.currentCommand || null : null,
pid: worker.pane ? worker.pane.pid || null : null,
active: worker.pane ? Boolean(worker.pane.active) : false,
dead: worker.pane ? Boolean(worker.pane.dead) : false,
},
intent: {
objective: worker.task.objective || '',
seedPaths: Array.isArray(worker.task.seedPaths) ? worker.task.seedPaths : []
},
outputs: {
summary: Array.isArray(worker.handoff.summary) ? worker.handoff.summary : [],
validation: Array.isArray(worker.handoff.validation) ? worker.handoff.validation : [],
remainingRisks: Array.isArray(worker.handoff.remainingRisks) ? worker.handoff.remainingRisks : []
},
artifacts: {
statusFile: worker.files.status,
taskFile: worker.files.task,
handoffFile: worker.files.handoff
}
}));
return validateCanonicalSnapshot({
schemaVersion: SESSION_SCHEMA_VERSION,
adapterId: 'dmux-tmux',
session: {
id: snapshot.sessionName,
kind: 'orchestrated',
state: deriveDmuxSessionState(snapshot),
repoRoot: snapshot.repoRoot || null,
sourceTarget
},
workers,
aggregates: buildAggregates(workers)
});
}
function deriveClaudeWorkerId(session) {
if (session.shortId && session.shortId !== 'no-id') {
return session.shortId;
}
return path.basename(session.filename || session.sessionPath || 'session', '.tmp');
}
function normalizeClaudeHistorySession(session, sourceTarget) {
const metadata = session.metadata || {};
const workerId = deriveClaudeWorkerId(session);
const worker = {
id: workerId,
label: metadata.title || session.filename || workerId,
state: 'recorded',
health: 'healthy',
branch: metadata.branch || null,
worktree: metadata.worktree || null,
runtime: {
kind: 'claude-session',
command: 'claude',
pid: null,
active: false,
dead: true,
},
intent: {
objective: metadata.inProgress && metadata.inProgress.length > 0
? metadata.inProgress[0]
: (metadata.title || ''),
seedPaths: parseContextSeedPaths(metadata.context)
},
outputs: {
summary: Array.isArray(metadata.completed) ? metadata.completed : [],
validation: [],
remainingRisks: metadata.notes ? [metadata.notes] : []
},
artifacts: {
sessionFile: session.sessionPath,
context: metadata.context || null
}
};
return validateCanonicalSnapshot({
schemaVersion: SESSION_SCHEMA_VERSION,
adapterId: 'claude-history',
session: {
id: workerId,
kind: 'history',
state: 'recorded',
repoRoot: metadata.worktree || null,
sourceTarget
},
workers: [worker],
aggregates: buildAggregates([worker])
});
}
module.exports = {
SESSION_SCHEMA_VERSION,
buildAggregates,
getFallbackSessionRecordingPath,
normalizeClaudeHistorySession,
normalizeDmuxSnapshot,
persistCanonicalSnapshot,
validateCanonicalSnapshot
};

View File

@@ -0,0 +1,160 @@
'use strict';
const fs = require('fs');
const path = require('path');
const sessionManager = require('../session-manager');
const sessionAliases = require('../session-aliases');
const { normalizeClaudeHistorySession, persistCanonicalSnapshot } = require('./canonical-session');
function parseClaudeTarget(target) {
if (typeof target !== 'string') {
return null;
}
for (const prefix of ['claude-history:', 'claude:', 'history:']) {
if (target.startsWith(prefix)) {
return target.slice(prefix.length).trim();
}
}
return null;
}
function isSessionFileTarget(target, cwd) {
if (typeof target !== 'string' || target.length === 0) {
return false;
}
const absoluteTarget = path.resolve(cwd, target);
return fs.existsSync(absoluteTarget)
&& fs.statSync(absoluteTarget).isFile()
&& absoluteTarget.endsWith('.tmp');
}
function hydrateSessionFromPath(sessionPath) {
const filename = path.basename(sessionPath);
const parsed = sessionManager.parseSessionFilename(filename);
if (!parsed) {
throw new Error(`Unsupported session file: ${sessionPath}`);
}
const content = sessionManager.getSessionContent(sessionPath);
const stats = fs.statSync(sessionPath);
return {
...parsed,
sessionPath,
content,
metadata: sessionManager.parseSessionMetadata(content),
stats: sessionManager.getSessionStats(content || ''),
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
};
}
function resolveSessionRecord(target, cwd) {
const explicitTarget = parseClaudeTarget(target);
if (explicitTarget) {
if (explicitTarget === 'latest') {
const [latest] = sessionManager.getAllSessions({ limit: 1 }).sessions;
if (!latest) {
throw new Error('No Claude session history found');
}
return {
session: sessionManager.getSessionById(latest.filename, true),
sourceTarget: {
type: 'claude-history',
value: 'latest'
}
};
}
const alias = sessionAliases.resolveAlias(explicitTarget);
if (alias) {
return {
session: hydrateSessionFromPath(alias.sessionPath),
sourceTarget: {
type: 'claude-alias',
value: explicitTarget
}
};
}
const session = sessionManager.getSessionById(explicitTarget, true);
if (!session) {
throw new Error(`Claude session not found: ${explicitTarget}`);
}
return {
session,
sourceTarget: {
type: 'claude-history',
value: explicitTarget
}
};
}
if (isSessionFileTarget(target, cwd)) {
return {
session: hydrateSessionFromPath(path.resolve(cwd, target)),
sourceTarget: {
type: 'session-file',
value: path.resolve(cwd, target)
}
};
}
throw new Error(`Unsupported Claude session target: ${target}`);
}
function createClaudeHistoryAdapter(options = {}) {
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
return {
id: 'claude-history',
description: 'Claude local session history and session-file snapshots',
targetTypes: ['claude-history', 'claude-alias', 'session-file'],
canOpen(target, context = {}) {
if (context.adapterId && context.adapterId !== 'claude-history') {
return false;
}
if (context.adapterId === 'claude-history') {
return true;
}
const cwd = context.cwd || process.cwd();
return parseClaudeTarget(target) !== null || isSessionFileTarget(target, cwd);
},
open(target, context = {}) {
const cwd = context.cwd || process.cwd();
return {
adapterId: 'claude-history',
getSnapshot() {
const { session, sourceTarget } = resolveSessionRecord(target, cwd);
const canonicalSnapshot = normalizeClaudeHistorySession(session, sourceTarget);
persistCanonicalSnapshotImpl(canonicalSnapshot, {
loadStateStoreImpl: options.loadStateStoreImpl,
persist: context.persistSnapshots !== false && options.persistSnapshots !== false,
recordingDir: context.recordingDir || options.recordingDir,
stateStore: options.stateStore
});
return canonicalSnapshot;
}
};
}
};
}
module.exports = {
createClaudeHistoryAdapter,
isSessionFileTarget,
parseClaudeTarget
};

View File

@@ -0,0 +1,90 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { collectSessionSnapshot } = require('../orchestration-session');
const { normalizeDmuxSnapshot, persistCanonicalSnapshot } = require('./canonical-session');
function isPlanFileTarget(target, cwd) {
if (typeof target !== 'string' || target.length === 0) {
return false;
}
const absoluteTarget = path.resolve(cwd, target);
return fs.existsSync(absoluteTarget)
&& fs.statSync(absoluteTarget).isFile()
&& path.extname(absoluteTarget) === '.json';
}
function isSessionNameTarget(target, cwd) {
if (typeof target !== 'string' || target.length === 0) {
return false;
}
const coordinationDir = path.resolve(cwd, '.claude', 'orchestration', target);
return fs.existsSync(coordinationDir) && fs.statSync(coordinationDir).isDirectory();
}
function buildSourceTarget(target, cwd) {
if (isPlanFileTarget(target, cwd)) {
return {
type: 'plan',
value: path.resolve(cwd, target)
};
}
return {
type: 'session',
value: target
};
}
function createDmuxTmuxAdapter(options = {}) {
const collectSessionSnapshotImpl = options.collectSessionSnapshotImpl || collectSessionSnapshot;
const persistCanonicalSnapshotImpl = options.persistCanonicalSnapshotImpl || persistCanonicalSnapshot;
return {
id: 'dmux-tmux',
description: 'Tmux/worktree orchestration snapshots from plan files or session names',
targetTypes: ['plan', 'session'],
canOpen(target, context = {}) {
if (context.adapterId && context.adapterId !== 'dmux-tmux') {
return false;
}
if (context.adapterId === 'dmux-tmux') {
return true;
}
const cwd = context.cwd || process.cwd();
return isPlanFileTarget(target, cwd) || isSessionNameTarget(target, cwd);
},
open(target, context = {}) {
const cwd = context.cwd || process.cwd();
return {
adapterId: 'dmux-tmux',
getSnapshot() {
const snapshot = collectSessionSnapshotImpl(target, cwd);
const canonicalSnapshot = normalizeDmuxSnapshot(snapshot, buildSourceTarget(target, cwd));
persistCanonicalSnapshotImpl(canonicalSnapshot, {
loadStateStoreImpl: options.loadStateStoreImpl,
persist: context.persistSnapshots !== false && options.persistSnapshots !== false,
recordingDir: context.recordingDir || options.recordingDir,
stateStore: options.stateStore
});
return canonicalSnapshot;
}
};
}
};
}
module.exports = {
createDmuxTmuxAdapter,
isPlanFileTarget,
isSessionNameTarget
};

View File

@@ -0,0 +1,127 @@
'use strict';
const { createClaudeHistoryAdapter } = require('./claude-history');
const { createDmuxTmuxAdapter } = require('./dmux-tmux');
const TARGET_TYPE_TO_ADAPTER_ID = Object.freeze({
plan: 'dmux-tmux',
session: 'dmux-tmux',
'claude-history': 'claude-history',
'claude-alias': 'claude-history',
'session-file': 'claude-history'
});
function buildDefaultAdapterOptions(options, adapterId) {
const sharedOptions = {
loadStateStoreImpl: options.loadStateStoreImpl,
persistSnapshots: options.persistSnapshots,
recordingDir: options.recordingDir,
stateStore: options.stateStore
};
return {
...sharedOptions,
...(options.adapterOptions && options.adapterOptions[adapterId]
? options.adapterOptions[adapterId]
: {})
};
}
function createDefaultAdapters(options = {}) {
return [
createClaudeHistoryAdapter(buildDefaultAdapterOptions(options, 'claude-history')),
createDmuxTmuxAdapter(buildDefaultAdapterOptions(options, 'dmux-tmux'))
];
}
function coerceTargetValue(value) {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error('Structured session targets require a non-empty string value');
}
return value.trim();
}
function normalizeStructuredTarget(target, context = {}) {
if (!target || typeof target !== 'object' || Array.isArray(target)) {
return {
target,
context: { ...context }
};
}
const value = coerceTargetValue(target.value);
const type = typeof target.type === 'string' ? target.type.trim() : '';
if (type.length === 0) {
throw new Error('Structured session targets require a non-empty type');
}
const adapterId = target.adapterId || TARGET_TYPE_TO_ADAPTER_ID[type] || context.adapterId || null;
const nextContext = {
...context,
adapterId
};
if (type === 'claude-history' || type === 'claude-alias') {
return {
target: `claude:${value}`,
context: nextContext
};
}
return {
target: value,
context: nextContext
};
}
function createAdapterRegistry(options = {}) {
const adapters = options.adapters || createDefaultAdapters(options);
return {
adapters,
getAdapter(id) {
const adapter = adapters.find(candidate => candidate.id === id);
if (!adapter) {
throw new Error(`Unknown session adapter: ${id}`);
}
return adapter;
},
listAdapters() {
return adapters.map(adapter => ({
id: adapter.id,
description: adapter.description || '',
targetTypes: Array.isArray(adapter.targetTypes) ? [...adapter.targetTypes] : []
}));
},
select(target, context = {}) {
const normalized = normalizeStructuredTarget(target, context);
const adapter = normalized.context.adapterId
? this.getAdapter(normalized.context.adapterId)
: adapters.find(candidate => candidate.canOpen(normalized.target, normalized.context));
if (!adapter) {
throw new Error(`No session adapter matched target: ${target}`);
}
return adapter;
},
open(target, context = {}) {
const normalized = normalizeStructuredTarget(target, context);
const adapter = this.select(normalized.target, normalized.context);
return adapter.open(normalized.target, normalized.context);
}
};
}
function inspectSessionTarget(target, options = {}) {
const registry = createAdapterRegistry(options);
return registry.open(target, options).getSnapshot();
}
module.exports = {
createAdapterRegistry,
createDefaultAdapters,
inspectSessionTarget,
normalizeStructuredTarget
};

View File

@@ -0,0 +1,136 @@
/**
* Session Aliases Library for Claude Code.
* Manages named aliases for session files, stored in ~/.claude/session-aliases.json.
*/
/** Internal alias storage entry */
export interface AliasEntry {
sessionPath: string;
createdAt: string;
updatedAt?: string;
title: string | null;
}
/** Alias data structure stored on disk */
export interface AliasStore {
version: string;
aliases: Record<string, AliasEntry>;
metadata: {
totalCount: number;
lastUpdated: string;
};
}
/** Resolved alias information returned by resolveAlias */
export interface ResolvedAlias {
alias: string;
sessionPath: string;
createdAt: string;
title: string | null;
}
/** Alias entry returned by listAliases */
export interface AliasListItem {
name: string;
sessionPath: string;
createdAt: string;
updatedAt?: string;
title: string | null;
}
/** Result from mutation operations (set, delete, rename, update, cleanup) */
export interface AliasResult {
success: boolean;
error?: string;
[key: string]: unknown;
}
export interface SetAliasResult extends AliasResult {
isNew?: boolean;
alias?: string;
sessionPath?: string;
title?: string | null;
}
export interface DeleteAliasResult extends AliasResult {
alias?: string;
deletedSessionPath?: string;
}
export interface RenameAliasResult extends AliasResult {
oldAlias?: string;
newAlias?: string;
sessionPath?: string;
}
export interface CleanupResult {
totalChecked: number;
removed: number;
removedAliases: Array<{ name: string; sessionPath: string }>;
error?: string;
}
export interface ListAliasesOptions {
/** Filter aliases by name or title (partial match, case-insensitive) */
search?: string | null;
/** Maximum number of aliases to return */
limit?: number | null;
}
/** Get the path to the aliases JSON file */
export function getAliasesPath(): string;
/** Load all aliases from disk. Returns default structure if file doesn't exist. */
export function loadAliases(): AliasStore;
/**
* Save aliases to disk with atomic write (temp file + rename).
* Creates backup before writing; restores on failure.
*/
export function saveAliases(aliases: AliasStore): boolean;
/**
* Resolve an alias name to its session data.
* @returns Alias data, or null if not found or invalid name
*/
export function resolveAlias(alias: string): ResolvedAlias | null;
/**
* Create or update an alias for a session.
* Alias names must be alphanumeric with dashes/underscores.
* Reserved names (list, help, remove, delete, create, set) are rejected.
*/
export function setAlias(alias: string, sessionPath: string, title?: string | null): SetAliasResult;
/**
* List all aliases, optionally filtered and limited.
* Results are sorted by updated time (newest first).
*/
export function listAliases(options?: ListAliasesOptions): AliasListItem[];
/** Delete an alias by name */
export function deleteAlias(alias: string): DeleteAliasResult;
/**
* Rename an alias. Fails if old alias doesn't exist or new alias already exists.
* New alias name must be alphanumeric with dashes/underscores.
*/
export function renameAlias(oldAlias: string, newAlias: string): RenameAliasResult;
/**
* Resolve an alias or pass through a session path.
* First tries to resolve as alias; if not found, returns the input as-is.
*/
export function resolveSessionAlias(aliasOrId: string): string;
/** Update the title of an existing alias. Pass null to clear. */
export function updateAliasTitle(alias: string, title: string | null): AliasResult;
/** Get all aliases that point to a specific session path */
export function getAliasesForSession(sessionPath: string): Array<{ name: string; createdAt: string; title: string | null }>;
/**
* Remove aliases whose sessions no longer exist.
* @param sessionExists - Function that returns true if a session path is valid
*/
export function cleanupAliases(sessionExists: (sessionPath: string) => boolean): CleanupResult;

View File

@@ -0,0 +1,481 @@
/**
* Session Aliases Library for Claude Code
* Manages session aliases stored in ~/.claude/session-aliases.json
*/
const fs = require('fs');
const path = require('path');
const {
getClaudeDir,
ensureDir,
readFile,
log
} = require('./utils');
// Aliases file path
function getAliasesPath() {
return path.join(getClaudeDir(), 'session-aliases.json');
}
// Current alias storage format version
const ALIAS_VERSION = '1.0';
/**
* Default aliases file structure
*/
function getDefaultAliases() {
return {
version: ALIAS_VERSION,
aliases: {},
metadata: {
totalCount: 0,
lastUpdated: new Date().toISOString()
}
};
}
/**
* Load aliases from file
* @returns {object} Aliases object
*/
function loadAliases() {
const aliasesPath = getAliasesPath();
if (!fs.existsSync(aliasesPath)) {
return getDefaultAliases();
}
const content = readFile(aliasesPath);
if (!content) {
return getDefaultAliases();
}
try {
const data = JSON.parse(content);
// Validate structure
if (!data.aliases || typeof data.aliases !== 'object') {
log('[Aliases] Invalid aliases file structure, resetting');
return getDefaultAliases();
}
// Ensure version field
if (!data.version) {
data.version = ALIAS_VERSION;
}
// Ensure metadata
if (!data.metadata) {
data.metadata = {
totalCount: Object.keys(data.aliases).length,
lastUpdated: new Date().toISOString()
};
}
return data;
} catch (err) {
log(`[Aliases] Error parsing aliases file: ${err.message}`);
return getDefaultAliases();
}
}
/**
* Save aliases to file with atomic write
* @param {object} aliases - Aliases object to save
* @returns {boolean} Success status
*/
function saveAliases(aliases) {
const aliasesPath = getAliasesPath();
const tempPath = aliasesPath + '.tmp';
const backupPath = aliasesPath + '.bak';
try {
// Update metadata
aliases.metadata = {
totalCount: Object.keys(aliases.aliases).length,
lastUpdated: new Date().toISOString()
};
const content = JSON.stringify(aliases, null, 2);
// Ensure directory exists
ensureDir(path.dirname(aliasesPath));
// Create backup if file exists
if (fs.existsSync(aliasesPath)) {
fs.copyFileSync(aliasesPath, backupPath);
}
// Atomic write: write to temp file, then rename
fs.writeFileSync(tempPath, content, 'utf8');
// On Windows, rename fails with EEXIST if destination exists, so delete first.
// On Unix/macOS, rename(2) atomically replaces the destination — skip the
// delete to avoid an unnecessary non-atomic window between unlink and rename.
if (process.platform === 'win32' && fs.existsSync(aliasesPath)) {
fs.unlinkSync(aliasesPath);
}
fs.renameSync(tempPath, aliasesPath);
// Remove backup on success
if (fs.existsSync(backupPath)) {
fs.unlinkSync(backupPath);
}
return true;
} catch (err) {
log(`[Aliases] Error saving aliases: ${err.message}`);
// Restore from backup if exists
if (fs.existsSync(backupPath)) {
try {
fs.copyFileSync(backupPath, aliasesPath);
log('[Aliases] Restored from backup');
} catch (restoreErr) {
log(`[Aliases] Failed to restore backup: ${restoreErr.message}`);
}
}
// Clean up temp file (best-effort)
try {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
} catch {
// Non-critical: temp file will be overwritten on next save
}
return false;
}
}
/**
* Resolve an alias to get session path
* @param {string} alias - Alias name to resolve
* @returns {object|null} Alias data or null if not found
*/
function resolveAlias(alias) {
if (!alias) return null;
// Validate alias name (alphanumeric, dash, underscore)
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
return null;
}
const data = loadAliases();
const aliasData = data.aliases[alias];
if (!aliasData) {
return null;
}
return {
alias,
sessionPath: aliasData.sessionPath,
createdAt: aliasData.createdAt,
title: aliasData.title || null
};
}
/**
* Set or update an alias for a session
* @param {string} alias - Alias name (alphanumeric, dash, underscore)
* @param {string} sessionPath - Session directory path
* @param {string} title - Optional title for the alias
* @returns {object} Result with success status and message
*/
function setAlias(alias, sessionPath, title = null) {
// Validate alias name
if (!alias || alias.length === 0) {
return { success: false, error: 'Alias name cannot be empty' };
}
// Validate session path
if (!sessionPath || typeof sessionPath !== 'string' || sessionPath.trim().length === 0) {
return { success: false, error: 'Session path cannot be empty' };
}
if (alias.length > 128) {
return { success: false, error: 'Alias name cannot exceed 128 characters' };
}
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
return { success: false, error: 'Alias name must contain only letters, numbers, dashes, and underscores' };
}
// Reserved alias names
const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set'];
if (reserved.includes(alias.toLowerCase())) {
return { success: false, error: `'${alias}' is a reserved alias name` };
}
const data = loadAliases();
const existing = data.aliases[alias];
const isNew = !existing;
data.aliases[alias] = {
sessionPath,
createdAt: existing ? existing.createdAt : new Date().toISOString(),
updatedAt: new Date().toISOString(),
title: title || null
};
if (saveAliases(data)) {
return {
success: true,
isNew,
alias,
sessionPath,
title: data.aliases[alias].title
};
}
return { success: false, error: 'Failed to save alias' };
}
/**
* List all aliases
* @param {object} options - Options object
* @param {string} options.search - Filter aliases by name (partial match)
* @param {number} options.limit - Maximum number of aliases to return
* @returns {Array} Array of alias objects
*/
function listAliases(options = {}) {
const { search = null, limit = null } = options;
const data = loadAliases();
let aliases = Object.entries(data.aliases).map(([name, info]) => ({
name,
sessionPath: info.sessionPath,
createdAt: info.createdAt,
updatedAt: info.updatedAt,
title: info.title
}));
// Sort by updated time (newest first)
aliases.sort((a, b) => (new Date(b.updatedAt || b.createdAt || 0).getTime() || 0) - (new Date(a.updatedAt || a.createdAt || 0).getTime() || 0));
// Apply search filter
if (search) {
const searchLower = search.toLowerCase();
aliases = aliases.filter(a =>
a.name.toLowerCase().includes(searchLower) ||
(a.title && a.title.toLowerCase().includes(searchLower))
);
}
// Apply limit
if (limit && limit > 0) {
aliases = aliases.slice(0, limit);
}
return aliases;
}
/**
* Delete an alias
* @param {string} alias - Alias name to delete
* @returns {object} Result with success status
*/
function deleteAlias(alias) {
const data = loadAliases();
if (!data.aliases[alias]) {
return { success: false, error: `Alias '${alias}' not found` };
}
const deleted = data.aliases[alias];
delete data.aliases[alias];
if (saveAliases(data)) {
return {
success: true,
alias,
deletedSessionPath: deleted.sessionPath
};
}
return { success: false, error: 'Failed to delete alias' };
}
/**
* Rename an alias
* @param {string} oldAlias - Current alias name
* @param {string} newAlias - New alias name
* @returns {object} Result with success status
*/
function renameAlias(oldAlias, newAlias) {
const data = loadAliases();
if (!data.aliases[oldAlias]) {
return { success: false, error: `Alias '${oldAlias}' not found` };
}
// Validate new alias name (same rules as setAlias)
if (!newAlias || newAlias.length === 0) {
return { success: false, error: 'New alias name cannot be empty' };
}
if (newAlias.length > 128) {
return { success: false, error: 'New alias name cannot exceed 128 characters' };
}
if (!/^[a-zA-Z0-9_-]+$/.test(newAlias)) {
return { success: false, error: 'New alias name must contain only letters, numbers, dashes, and underscores' };
}
const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set'];
if (reserved.includes(newAlias.toLowerCase())) {
return { success: false, error: `'${newAlias}' is a reserved alias name` };
}
if (data.aliases[newAlias]) {
return { success: false, error: `Alias '${newAlias}' already exists` };
}
const aliasData = data.aliases[oldAlias];
delete data.aliases[oldAlias];
aliasData.updatedAt = new Date().toISOString();
data.aliases[newAlias] = aliasData;
if (saveAliases(data)) {
return {
success: true,
oldAlias,
newAlias,
sessionPath: aliasData.sessionPath
};
}
// Restore old alias and remove new alias on failure
data.aliases[oldAlias] = aliasData;
delete data.aliases[newAlias];
// Attempt to persist the rollback
saveAliases(data);
return { success: false, error: 'Failed to save renamed alias — rolled back to original' };
}
/**
* Get session path by alias (convenience function)
* @param {string} aliasOrId - Alias name or session ID
* @returns {string|null} Session path or null if not found
*/
function resolveSessionAlias(aliasOrId) {
// First try to resolve as alias
const resolved = resolveAlias(aliasOrId);
if (resolved) {
return resolved.sessionPath;
}
// If not an alias, return as-is (might be a session path)
return aliasOrId;
}
/**
* Update alias title
* @param {string} alias - Alias name
* @param {string|null} title - New title (string or null to clear)
* @returns {object} Result with success status
*/
function updateAliasTitle(alias, title) {
if (title !== null && typeof title !== 'string') {
return { success: false, error: 'Title must be a string or null' };
}
const data = loadAliases();
if (!data.aliases[alias]) {
return { success: false, error: `Alias '${alias}' not found` };
}
data.aliases[alias].title = title || null;
data.aliases[alias].updatedAt = new Date().toISOString();
if (saveAliases(data)) {
return {
success: true,
alias,
title
};
}
return { success: false, error: 'Failed to update alias title' };
}
/**
* Get all aliases for a specific session
* @param {string} sessionPath - Session path to find aliases for
* @returns {Array} Array of alias names
*/
function getAliasesForSession(sessionPath) {
const data = loadAliases();
const aliases = [];
for (const [name, info] of Object.entries(data.aliases)) {
if (info.sessionPath === sessionPath) {
aliases.push({
name,
createdAt: info.createdAt,
title: info.title
});
}
}
return aliases;
}
/**
* Clean up aliases for non-existent sessions
* @param {Function} sessionExists - Function to check if session exists
* @returns {object} Cleanup result
*/
function cleanupAliases(sessionExists) {
if (typeof sessionExists !== 'function') {
return { totalChecked: 0, removed: 0, removedAliases: [], error: 'sessionExists must be a function' };
}
const data = loadAliases();
const removed = [];
for (const [name, info] of Object.entries(data.aliases)) {
if (!sessionExists(info.sessionPath)) {
removed.push({ name, sessionPath: info.sessionPath });
delete data.aliases[name];
}
}
if (removed.length > 0 && !saveAliases(data)) {
log('[Aliases] Failed to save after cleanup');
return {
success: false,
totalChecked: Object.keys(data.aliases).length + removed.length,
removed: removed.length,
removedAliases: removed,
error: 'Failed to save after cleanup'
};
}
return {
success: true,
totalChecked: Object.keys(data.aliases).length + removed.length,
removed: removed.length,
removedAliases: removed
};
}
module.exports = {
getAliasesPath,
loadAliases,
saveAliases,
resolveAlias,
setAlias,
listAliases,
deleteAlias,
renameAlias,
resolveSessionAlias,
updateAliasTitle,
getAliasesForSession,
cleanupAliases
};

View File

@@ -0,0 +1,132 @@
/**
* Session Manager Library for Claude Code.
* Provides CRUD operations for session files stored as markdown in
* ~/.claude/session-data/ with legacy read compatibility for ~/.claude/sessions/.
*/
/** Parsed metadata from a session filename */
export interface SessionFilenameMeta {
/** Original filename */
filename: string;
/** Short ID extracted from filename, or "no-id" for old format */
shortId: string;
/** Date string in YYYY-MM-DD format */
date: string;
/** Parsed Date object from the date string */
datetime: Date;
}
/** Metadata parsed from session markdown content */
export interface SessionMetadata {
title: string | null;
date: string | null;
started: string | null;
lastUpdated: string | null;
completed: string[];
inProgress: string[];
notes: string;
context: string;
}
/** Statistics computed from session content */
export interface SessionStats {
totalItems: number;
completedItems: number;
inProgressItems: number;
lineCount: number;
hasNotes: boolean;
hasContext: boolean;
}
/** A session object returned by getAllSessions and getSessionById */
export interface Session extends SessionFilenameMeta {
/** Full filesystem path to the session file */
sessionPath: string;
/** Whether the file has any content */
hasContent?: boolean;
/** File size in bytes */
size: number;
/** Last modification time */
modifiedTime: Date;
/** File creation time (falls back to ctime on Linux) */
createdTime: Date;
/** Session markdown content (only when includeContent=true) */
content?: string | null;
/** Parsed metadata (only when includeContent=true) */
metadata?: SessionMetadata;
/** Session statistics (only when includeContent=true) */
stats?: SessionStats;
}
/** Pagination result from getAllSessions */
export interface SessionListResult {
sessions: Session[];
total: number;
offset: number;
limit: number;
hasMore: boolean;
}
export interface GetAllSessionsOptions {
/** Maximum number of sessions to return (default: 50) */
limit?: number;
/** Number of sessions to skip (default: 0) */
offset?: number;
/** Filter by date in YYYY-MM-DD format */
date?: string | null;
/** Search in short ID */
search?: string | null;
}
/**
* Parse a session filename to extract date and short ID.
* @returns Parsed metadata, or null if the filename doesn't match the expected pattern
*/
export function parseSessionFilename(filename: string): SessionFilenameMeta | null;
/** Get the full filesystem path for a session filename */
export function getSessionPath(filename: string): string;
/**
* Read session markdown content from disk.
* @returns Content string, or null if the file doesn't exist
*/
export function getSessionContent(sessionPath: string): string | null;
/** Parse session metadata from markdown content */
export function parseSessionMetadata(content: string | null): SessionMetadata;
/**
* Calculate statistics for a session.
* Accepts either a file path (absolute, ending in .tmp) or pre-read content string.
* Supports both Unix (/path/to/session.tmp) and Windows (C:\path\to\session.tmp) paths.
*/
export function getSessionStats(sessionPathOrContent: string): SessionStats;
/** Get the title from a session file, or "Untitled Session" if none */
export function getSessionTitle(sessionPath: string): string;
/** Get human-readable file size (e.g., "1.2 KB") */
export function getSessionSize(sessionPath: string): string;
/** Get all sessions with optional filtering and pagination */
export function getAllSessions(options?: GetAllSessionsOptions): SessionListResult;
/**
* Find a session by short ID or filename.
* @param sessionId - Short ID prefix, full filename, or filename without .tmp
* @param includeContent - Whether to read and parse the session content
*/
export function getSessionById(sessionId: string, includeContent?: boolean): Session | null;
/** Write markdown content to a session file */
export function writeSessionContent(sessionPath: string, content: string): boolean;
/** Append content to an existing session file */
export function appendSessionContent(sessionPath: string, content: string): boolean;
/** Delete a session file */
export function deleteSession(sessionPath: string): boolean;
/** Check if a session file exists and is a regular file */
export function sessionExists(sessionPath: string): boolean;

View File

@@ -0,0 +1,533 @@
/**
* Session Manager Library for Claude Code
* Provides core session CRUD operations for listing, loading, and managing sessions
*
* Sessions are stored as markdown files in ~/.claude/session-data/ with
* legacy read compatibility for ~/.claude/sessions/:
* - YYYY-MM-DD-session.tmp (old format)
* - YYYY-MM-DD-<short-id>-session.tmp (new format)
*/
const fs = require('fs');
const path = require('path');
const {
getSessionsDir,
getSessionSearchDirs,
readFile,
log
} = require('./utils');
// Session filename pattern: YYYY-MM-DD-[session-id]-session.tmp
// The session-id is optional (old format) and can include letters, digits,
// underscores, and hyphens, but must not start with a hyphen.
// Matches: "2026-02-01-session.tmp", "2026-02-01-a1b2c3d4-session.tmp",
// "2026-02-01-frontend-worktree-1-session.tmp", and
// "2026-02-01-ChezMoi_2-session.tmp"
const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-zA-Z0-9_][a-zA-Z0-9_-]*))?-session\.tmp$/;
/**
* Parse session filename to extract metadata
* @param {string} filename - Session filename (e.g., "2026-01-17-abc123-session.tmp" or "2026-01-17-session.tmp")
* @returns {object|null} Parsed metadata or null if invalid
*/
function parseSessionFilename(filename) {
if (!filename || typeof filename !== 'string') return null;
const match = filename.match(SESSION_FILENAME_REGEX);
if (!match) return null;
const dateStr = match[1];
// Validate date components are calendar-accurate (not just format)
const [year, month, day] = dateStr.split('-').map(Number);
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
// Reject impossible dates like Feb 31, Apr 31 — Date constructor rolls
// over invalid days (e.g., Feb 31 → Mar 3), so check month roundtrips
const d = new Date(year, month - 1, day);
if (d.getMonth() !== month - 1 || d.getDate() !== day) return null;
// match[2] is undefined for old format (no ID)
const shortId = match[2] || 'no-id';
return {
filename,
shortId,
date: dateStr,
// Use local-time constructor (consistent with validation on line 40)
// new Date(dateStr) interprets YYYY-MM-DD as UTC midnight which shows
// as the previous day in negative UTC offset timezones
datetime: new Date(year, month - 1, day)
};
}
/**
* Get the full path to a session file
* @param {string} filename - Session filename
* @returns {string} Full path to session file
*/
function getSessionPath(filename) {
return path.join(getSessionsDir(), filename);
}
function getSessionCandidates(options = {}) {
const {
date = null,
search = null
} = options;
const candidates = [];
for (const sessionsDir of getSessionSearchDirs()) {
if (!fs.existsSync(sessionsDir)) {
continue;
}
let entries;
try {
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
} catch (error) {
log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`);
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
const filename = entry.name;
const metadata = parseSessionFilename(filename);
if (!metadata) continue;
if (date && metadata.date !== date) continue;
if (search && !metadata.shortId.includes(search)) continue;
const sessionPath = path.join(sessionsDir, filename);
let stats;
try {
stats = fs.statSync(sessionPath);
} catch (error) {
log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`);
continue;
}
candidates.push({
...metadata,
sessionPath,
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
});
}
}
const deduped = [];
const seenFilenames = new Set();
for (const session of candidates) {
if (seenFilenames.has(session.filename)) {
continue;
}
seenFilenames.add(session.filename);
deduped.push(session);
}
deduped.sort((a, b) => b.modifiedTime - a.modifiedTime);
return deduped;
}
function buildSessionRecord(sessionPath, metadata) {
let stats;
try {
stats = fs.statSync(sessionPath);
} catch (error) {
log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`);
return null;
}
return {
...metadata,
sessionPath,
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
};
}
function sessionMatchesId(metadata, normalizedSessionId) {
const filename = metadata.filename;
const shortIdMatch = metadata.shortId !== 'no-id' && metadata.shortId.startsWith(normalizedSessionId);
const filenameMatch = filename === normalizedSessionId || filename === `${normalizedSessionId}.tmp`;
const noIdMatch = metadata.shortId === 'no-id' && filename === `${normalizedSessionId}-session.tmp`;
return shortIdMatch || filenameMatch || noIdMatch;
}
function getMatchingSessionCandidates(normalizedSessionId) {
const matches = [];
const seenFilenames = new Set();
for (const sessionsDir of getSessionSearchDirs()) {
if (!fs.existsSync(sessionsDir)) {
continue;
}
let entries;
try {
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
} catch (error) {
log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`);
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
const metadata = parseSessionFilename(entry.name);
if (!metadata || !sessionMatchesId(metadata, normalizedSessionId)) {
continue;
}
if (seenFilenames.has(metadata.filename)) {
continue;
}
const sessionPath = path.join(sessionsDir, metadata.filename);
const sessionRecord = buildSessionRecord(sessionPath, metadata);
if (!sessionRecord) {
continue;
}
seenFilenames.add(metadata.filename);
matches.push(sessionRecord);
}
}
matches.sort((a, b) => b.modifiedTime - a.modifiedTime);
return matches;
}
/**
* Read and parse session markdown content
* @param {string} sessionPath - Full path to session file
* @returns {string|null} Session content or null if not found
*/
function getSessionContent(sessionPath) {
return readFile(sessionPath);
}
/**
* Parse session metadata from markdown content
* @param {string} content - Session markdown content
* @returns {object} Parsed metadata
*/
function parseSessionMetadata(content) {
const metadata = {
title: null,
date: null,
started: null,
lastUpdated: null,
project: null,
branch: null,
worktree: null,
completed: [],
inProgress: [],
notes: '',
context: ''
};
if (!content) return metadata;
// Extract title from first heading
const titleMatch = content.match(/^#\s+(.+)$/m);
if (titleMatch) {
metadata.title = titleMatch[1].trim();
}
// Extract date
const dateMatch = content.match(/\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})/);
if (dateMatch) {
metadata.date = dateMatch[1];
}
// Extract started time
const startedMatch = content.match(/\*\*Started:\*\*\s*([\d:]+)/);
if (startedMatch) {
metadata.started = startedMatch[1];
}
// Extract last updated
const updatedMatch = content.match(/\*\*Last Updated:\*\*\s*([\d:]+)/);
if (updatedMatch) {
metadata.lastUpdated = updatedMatch[1];
}
// Extract control-plane metadata
const projectMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
if (projectMatch) {
metadata.project = projectMatch[1].trim();
}
const branchMatch = content.match(/\*\*Branch:\*\*\s*(.+)$/m);
if (branchMatch) {
metadata.branch = branchMatch[1].trim();
}
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
if (worktreeMatch) {
metadata.worktree = worktreeMatch[1].trim();
}
// Extract completed items
const completedSection = content.match(/### Completed\s*\n([\s\S]*?)(?=###|\n\n|$)/);
if (completedSection) {
const items = completedSection[1].match(/- \[x\]\s*(.+)/g);
if (items) {
metadata.completed = items.map(item => item.replace(/- \[x\]\s*/, '').trim());
}
}
// Extract in-progress items
const progressSection = content.match(/### In Progress\s*\n([\s\S]*?)(?=###|\n\n|$)/);
if (progressSection) {
const items = progressSection[1].match(/- \[ \]\s*(.+)/g);
if (items) {
metadata.inProgress = items.map(item => item.replace(/- \[ \]\s*/, '').trim());
}
}
// Extract notes
const notesSection = content.match(/### Notes for Next Session\s*\n([\s\S]*?)(?=###|\n\n|$)/);
if (notesSection) {
metadata.notes = notesSection[1].trim();
}
// Extract context to load
const contextSection = content.match(/### Context to Load\s*\n```\n([\s\S]*?)```/);
if (contextSection) {
metadata.context = contextSection[1].trim();
}
return metadata;
}
/**
* Calculate statistics for a session
* @param {string} sessionPathOrContent - Full path to session file, OR
* the pre-read content string (to avoid redundant disk reads when
* the caller already has the content loaded).
* @returns {object} Statistics object
*/
function getSessionStats(sessionPathOrContent) {
// Accept pre-read content string to avoid redundant file reads.
// If the argument looks like a file path (no newlines, ends with .tmp,
// starts with / on Unix or drive letter on Windows), read from disk.
// Otherwise treat it as content.
const looksLikePath = typeof sessionPathOrContent === 'string' &&
!sessionPathOrContent.includes('\n') &&
sessionPathOrContent.endsWith('.tmp') &&
(sessionPathOrContent.startsWith('/') || /^[A-Za-z]:[/\\]/.test(sessionPathOrContent));
const content = looksLikePath
? getSessionContent(sessionPathOrContent)
: sessionPathOrContent;
const metadata = parseSessionMetadata(content);
return {
totalItems: metadata.completed.length + metadata.inProgress.length,
completedItems: metadata.completed.length,
inProgressItems: metadata.inProgress.length,
lineCount: content ? content.split('\n').length : 0,
hasNotes: !!metadata.notes,
hasContext: !!metadata.context
};
}
/**
* Get all sessions with optional filtering and pagination
* @param {object} options - Options object
* @param {number} options.limit - Maximum number of sessions to return
* @param {number} options.offset - Number of sessions to skip
* @param {string} options.date - Filter by date (YYYY-MM-DD format)
* @param {string} options.search - Search in short ID
* @returns {object} Object with sessions array and pagination info
*/
function getAllSessions(options = {}) {
const {
limit: rawLimit = 50,
offset: rawOffset = 0,
date = null,
search = null
} = options;
// Clamp offset and limit to safe non-negative integers.
// Without this, negative offset causes slice() to count from the end,
// and NaN values cause slice() to return empty or unexpected results.
// Note: cannot use `|| default` because 0 is falsy — use isNaN instead.
const offsetNum = Number(rawOffset);
const offset = Number.isNaN(offsetNum) ? 0 : Math.max(0, Math.floor(offsetNum));
const limitNum = Number(rawLimit);
const limit = Number.isNaN(limitNum) ? 50 : Math.max(1, Math.floor(limitNum));
const sessions = getSessionCandidates({ date, search });
if (sessions.length === 0) {
return { sessions: [], total: 0, offset, limit, hasMore: false };
}
// Apply pagination
const paginatedSessions = sessions.slice(offset, offset + limit);
return {
sessions: paginatedSessions,
total: sessions.length,
offset,
limit,
hasMore: offset + limit < sessions.length
};
}
/**
* Get a single session by ID (short ID or full path)
* @param {string} sessionId - Short ID or session filename
* @param {boolean} includeContent - Include session content
* @returns {object|null} Session object or null if not found
*/
function getSessionById(sessionId, includeContent = false) {
if (typeof sessionId !== 'string') {
return null;
}
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return null;
}
const sessions = getMatchingSessionCandidates(normalizedSessionId);
for (const session of sessions) {
const sessionRecord = { ...session };
if (includeContent) {
sessionRecord.content = getSessionContent(sessionRecord.sessionPath);
sessionRecord.metadata = parseSessionMetadata(sessionRecord.content);
// Pass pre-read content to avoid a redundant disk read
sessionRecord.stats = getSessionStats(sessionRecord.content || '');
}
return sessionRecord;
}
return null;
}
/**
* Get session title from content
* @param {string} sessionPath - Full path to session file
* @returns {string} Title or default text
*/
function getSessionTitle(sessionPath) {
const content = getSessionContent(sessionPath);
const metadata = parseSessionMetadata(content);
return metadata.title || 'Untitled Session';
}
/**
* Format session size in human-readable format
* @param {string} sessionPath - Full path to session file
* @returns {string} Formatted size (e.g., "1.2 KB")
*/
function getSessionSize(sessionPath) {
let stats;
try {
stats = fs.statSync(sessionPath);
} catch {
return '0 B';
}
const size = stats.size;
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Write session content to file
* @param {string} sessionPath - Full path to session file
* @param {string} content - Markdown content to write
* @returns {boolean} Success status
*/
function writeSessionContent(sessionPath, content) {
try {
fs.writeFileSync(sessionPath, content, 'utf8');
return true;
} catch (err) {
log(`[SessionManager] Error writing session: ${err.message}`);
return false;
}
}
/**
* Append content to a session
* @param {string} sessionPath - Full path to session file
* @param {string} content - Content to append
* @returns {boolean} Success status
*/
function appendSessionContent(sessionPath, content) {
try {
fs.appendFileSync(sessionPath, content, 'utf8');
return true;
} catch (err) {
log(`[SessionManager] Error appending to session: ${err.message}`);
return false;
}
}
/**
* Delete a session file
* @param {string} sessionPath - Full path to session file
* @returns {boolean} Success status
*/
function deleteSession(sessionPath) {
try {
if (fs.existsSync(sessionPath)) {
fs.unlinkSync(sessionPath);
return true;
}
return false;
} catch (err) {
log(`[SessionManager] Error deleting session: ${err.message}`);
return false;
}
}
/**
* Check if a session exists
* @param {string} sessionPath - Full path to session file
* @returns {boolean} True if session exists
*/
function sessionExists(sessionPath) {
try {
return fs.statSync(sessionPath).isFile();
} catch {
return false;
}
}
module.exports = {
parseSessionFilename,
getSessionPath,
getSessionContent,
parseSessionMetadata,
getSessionStats,
getSessionTitle,
getSessionSize,
getAllSessions,
getSessionById,
writeSessionContent,
appendSessionContent,
deleteSession,
sessionExists
};

View File

@@ -0,0 +1,86 @@
'use strict';
/**
* Split a shell command into segments by operators (&&, ||, ;, &)
* while respecting quoting (single/double) and escaped characters.
* Redirection operators (&>, >&, 2>&1) are NOT treated as separators.
*/
function splitShellSegments(command) {
const segments = [];
let current = '';
let quote = null;
for (let i = 0; i < command.length; i++) {
const ch = command[i];
// Inside quotes: handle escapes and closing quote
if (quote) {
if (ch === '\\' && i + 1 < command.length) {
current += ch + command[i + 1];
i++;
continue;
}
if (ch === quote) quote = null;
current += ch;
continue;
}
// Backslash escape outside quotes
if (ch === '\\' && i + 1 < command.length) {
current += ch + command[i + 1];
i++;
continue;
}
// Opening quote
if (ch === '"' || ch === "'") {
quote = ch;
current += ch;
continue;
}
const next = command[i + 1] || '';
const prev = i > 0 ? command[i - 1] : '';
// && operator
if (ch === '&' && next === '&') {
if (current.trim()) segments.push(current.trim());
current = '';
i++;
continue;
}
// || operator
if (ch === '|' && next === '|') {
if (current.trim()) segments.push(current.trim());
current = '';
i++;
continue;
}
// ; separator
if (ch === ';') {
if (current.trim()) segments.push(current.trim());
current = '';
continue;
}
// Single & — but skip redirection patterns (&>, >&, digit>&)
if (ch === '&' && next !== '&') {
if (next === '>' || prev === '>') {
current += ch;
continue;
}
if (current.trim()) segments.push(current.trim());
current = '';
continue;
}
current += ch;
}
if (current.trim()) segments.push(current.trim());
return segments;
}
module.exports = { splitShellSegments };

View File

@@ -0,0 +1,401 @@
'use strict';
const health = require('./health');
const tracker = require('./tracker');
const versioning = require('./versioning');
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const SPARKLINE_CHARS = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
const EMPTY_BLOCK = '\u2591';
const FILL_BLOCK = '\u2588';
const DEFAULT_PANEL_WIDTH = 64;
const VALID_PANELS = new Set(['success-rate', 'failures', 'amendments', 'versions']);
function sparkline(values) {
if (!Array.isArray(values) || values.length === 0) {
return '';
}
return values.map(value => {
if (value === null || value === undefined) {
return EMPTY_BLOCK;
}
const clamped = Math.max(0, Math.min(1, value));
const index = Math.min(Math.round(clamped * (SPARKLINE_CHARS.length - 1)), SPARKLINE_CHARS.length - 1);
return SPARKLINE_CHARS[index];
}).join('');
}
function horizontalBar(value, max, width) {
if (max <= 0 || width <= 0) {
return EMPTY_BLOCK.repeat(width || 0);
}
const filled = Math.round((Math.min(value, max) / max) * width);
const empty = width - filled;
return FILL_BLOCK.repeat(filled) + EMPTY_BLOCK.repeat(empty);
}
function panelBox(title, lines, width) {
const innerWidth = width || DEFAULT_PANEL_WIDTH;
const output = [];
output.push('\u250C\u2500 ' + title + ' ' + '\u2500'.repeat(Math.max(0, innerWidth - title.length - 4)) + '\u2510');
for (const line of lines) {
const truncated = line.length > innerWidth - 2
? line.slice(0, innerWidth - 2)
: line;
output.push('\u2502 ' + truncated.padEnd(innerWidth - 2) + '\u2502');
}
output.push('\u2514' + '\u2500'.repeat(innerWidth - 1) + '\u2518');
return output.join('\n');
}
function bucketByDay(records, nowMs, days) {
const buckets = [];
for (let i = days - 1; i >= 0; i -= 1) {
const dayEnd = nowMs - (i * DAY_IN_MS);
const dayStart = dayEnd - DAY_IN_MS;
const dateStr = new Date(dayEnd).toISOString().slice(0, 10);
buckets.push({ date: dateStr, start: dayStart, end: dayEnd, records: [] });
}
for (const record of records) {
const recordMs = Date.parse(record.recorded_at);
if (Number.isNaN(recordMs)) {
continue;
}
for (const bucket of buckets) {
if (recordMs > bucket.start && recordMs <= bucket.end) {
bucket.records.push(record);
break;
}
}
}
return buckets.map(bucket => ({
date: bucket.date,
rate: bucket.records.length > 0
? health.calculateSuccessRate(bucket.records)
: null,
runs: bucket.records.length,
}));
}
function getTrendArrow(successRate7d, successRate30d) {
if (successRate7d === null || successRate30d === null) {
return '\u2192';
}
const delta = successRate7d - successRate30d;
if (delta >= 0.1) {
return '\u2197';
}
if (delta <= -0.1) {
return '\u2198';
}
return '\u2192';
}
function formatPercent(value) {
if (value === null) {
return 'n/a';
}
return `${Math.round(value * 100)}%`;
}
function groupRecordsBySkill(records) {
return records.reduce((grouped, record) => {
const skillId = record.skill_id;
if (!grouped.has(skillId)) {
grouped.set(skillId, []);
}
grouped.get(skillId).push(record);
return grouped;
}, new Map());
}
function renderSuccessRatePanel(records, skills, options = {}) {
const nowMs = Date.parse(options.now || new Date().toISOString());
const days = options.days || 30;
const width = options.width || DEFAULT_PANEL_WIDTH;
const recordsBySkill = groupRecordsBySkill(records);
const skillData = [];
const skillIds = Array.from(new Set([
...Array.from(recordsBySkill.keys()),
...skills.map(s => s.skill_id),
])).sort();
for (const skillId of skillIds) {
const skillRecords = recordsBySkill.get(skillId) || [];
const dailyRates = bucketByDay(skillRecords, nowMs, days);
const rateValues = dailyRates.map(b => b.rate);
const records7d = health.filterRecordsWithinDays(skillRecords, nowMs, 7);
const records30d = health.filterRecordsWithinDays(skillRecords, nowMs, 30);
const current7d = health.calculateSuccessRate(records7d);
const current30d = health.calculateSuccessRate(records30d);
const trend = getTrendArrow(current7d, current30d);
skillData.push({
skill_id: skillId,
daily_rates: dailyRates,
sparkline: sparkline(rateValues),
current_7d: current7d,
trend,
});
}
const lines = [];
if (skillData.length === 0) {
lines.push('No skill execution data available.');
} else {
for (const skill of skillData) {
const nameCol = skill.skill_id.slice(0, 14).padEnd(14);
const sparkCol = skill.sparkline.slice(0, 30);
const rateCol = formatPercent(skill.current_7d).padStart(5);
lines.push(`${nameCol} ${sparkCol} ${rateCol} ${skill.trend}`);
}
}
return {
text: panelBox('Success Rate (30d)', lines, width),
data: { skills: skillData },
};
}
function renderFailureClusterPanel(records, options = {}) {
const width = options.width || DEFAULT_PANEL_WIDTH;
const failures = records.filter(r => r.outcome === 'failure');
const clusterMap = new Map();
for (const record of failures) {
const reason = (record.failure_reason || 'unknown').toLowerCase().trim();
if (!clusterMap.has(reason)) {
clusterMap.set(reason, { count: 0, skill_ids: new Set() });
}
const cluster = clusterMap.get(reason);
cluster.count += 1;
cluster.skill_ids.add(record.skill_id);
}
const clusters = Array.from(clusterMap.entries())
.map(([pattern, data]) => ({
pattern,
count: data.count,
skill_ids: Array.from(data.skill_ids).sort(),
percentage: failures.length > 0
? Math.round((data.count / failures.length) * 100)
: 0,
}))
.sort((a, b) => b.count - a.count || a.pattern.localeCompare(b.pattern));
const maxCount = clusters.length > 0 ? clusters[0].count : 0;
const lines = [];
if (clusters.length === 0) {
lines.push('No failure patterns detected.');
} else {
for (const cluster of clusters) {
const label = cluster.pattern.slice(0, 20).padEnd(20);
const bar = horizontalBar(cluster.count, maxCount, 16);
const skillCount = cluster.skill_ids.length;
const suffix = skillCount === 1 ? 'skill' : 'skills';
lines.push(`${label} ${bar} ${String(cluster.count).padStart(3)} (${skillCount} ${suffix})`);
}
}
return {
text: panelBox('Failure Patterns', lines, width),
data: { clusters, total_failures: failures.length },
};
}
function renderAmendmentPanel(skillsById, options = {}) {
const width = options.width || DEFAULT_PANEL_WIDTH;
const amendments = [];
for (const [skillId, skill] of skillsById) {
if (!skill.skill_dir) {
continue;
}
const log = versioning.getEvolutionLog(skill.skill_dir, 'amendments');
for (const entry of log) {
const status = typeof entry.status === 'string' ? entry.status : null;
const isPending = status
? health.PENDING_AMENDMENT_STATUSES.has(status)
: entry.event === 'proposal';
if (isPending) {
amendments.push({
skill_id: skillId,
event: entry.event || 'proposal',
status: status || 'pending',
created_at: entry.created_at || null,
});
}
}
}
amendments.sort((a, b) => {
const timeA = a.created_at ? Date.parse(a.created_at) : 0;
const timeB = b.created_at ? Date.parse(b.created_at) : 0;
return timeB - timeA;
});
const lines = [];
if (amendments.length === 0) {
lines.push('No pending amendments.');
} else {
for (const amendment of amendments) {
const name = amendment.skill_id.slice(0, 14).padEnd(14);
const event = amendment.event.padEnd(10);
const status = amendment.status.padEnd(10);
const time = amendment.created_at ? amendment.created_at.slice(0, 19) : '-';
lines.push(`${name} ${event} ${status} ${time}`);
}
lines.push('');
lines.push(`${amendments.length} amendment${amendments.length === 1 ? '' : 's'} pending review`);
}
return {
text: panelBox('Pending Amendments', lines, width),
data: { amendments, total: amendments.length },
};
}
function renderVersionTimelinePanel(skillsById, options = {}) {
const width = options.width || DEFAULT_PANEL_WIDTH;
const skillVersions = [];
for (const [skillId, skill] of skillsById) {
if (!skill.skill_dir) {
continue;
}
const versions = versioning.listVersions(skill.skill_dir);
if (versions.length === 0) {
continue;
}
const amendmentLog = versioning.getEvolutionLog(skill.skill_dir, 'amendments');
const reasonByVersion = new Map();
for (const entry of amendmentLog) {
if (entry.version && entry.reason) {
reasonByVersion.set(entry.version, entry.reason);
}
}
skillVersions.push({
skill_id: skillId,
versions: versions.map(v => ({
version: v.version,
created_at: v.created_at,
reason: reasonByVersion.get(v.version) || null,
})),
});
}
skillVersions.sort((a, b) => a.skill_id.localeCompare(b.skill_id));
const lines = [];
if (skillVersions.length === 0) {
lines.push('No version history available.');
} else {
for (const skill of skillVersions) {
lines.push(skill.skill_id);
for (const version of skill.versions) {
const date = version.created_at ? version.created_at.slice(0, 10) : '-';
const reason = version.reason || '-';
lines.push(` v${version.version} \u2500\u2500 ${date} \u2500\u2500 ${reason}`);
}
}
}
return {
text: panelBox('Version History', lines, width),
data: { skills: skillVersions },
};
}
function renderDashboard(options = {}) {
const now = options.now || new Date().toISOString();
const nowMs = Date.parse(now);
if (Number.isNaN(nowMs)) {
throw new Error(`Invalid now timestamp: ${now}`);
}
const dashboardOptions = { ...options, now };
const records = tracker.readSkillExecutionRecords(dashboardOptions);
const skillsById = health.discoverSkills(dashboardOptions);
const report = health.collectSkillHealth(dashboardOptions);
const summary = health.summarizeHealthReport(report);
const panelRenderers = {
'success-rate': () => renderSuccessRatePanel(records, report.skills, dashboardOptions),
'failures': () => renderFailureClusterPanel(records, dashboardOptions),
'amendments': () => renderAmendmentPanel(skillsById, dashboardOptions),
'versions': () => renderVersionTimelinePanel(skillsById, dashboardOptions),
};
const selectedPanel = options.panel || null;
if (selectedPanel && !VALID_PANELS.has(selectedPanel)) {
throw new Error(`Unknown panel: ${selectedPanel}. Valid panels: ${Array.from(VALID_PANELS).join(', ')}`);
}
const panels = {};
const textParts = [];
const header = [
'ECC Skill Health Dashboard',
`Generated: ${now}`,
`Skills: ${summary.total_skills} total, ${summary.healthy_skills} healthy, ${summary.declining_skills} declining`,
'',
];
textParts.push(header.join('\n'));
if (selectedPanel) {
const result = panelRenderers[selectedPanel]();
panels[selectedPanel] = result.data;
textParts.push(result.text);
} else {
for (const [panelName, renderer] of Object.entries(panelRenderers)) {
const result = renderer();
panels[panelName] = result.data;
textParts.push(result.text);
}
}
const text = textParts.join('\n\n') + '\n';
const data = {
generated_at: now,
summary,
panels,
};
return { text, data };
}
module.exports = {
VALID_PANELS,
bucketByDay,
horizontalBar,
panelBox,
renderAmendmentPanel,
renderDashboard,
renderFailureClusterPanel,
renderSuccessRatePanel,
renderVersionTimelinePanel,
sparkline,
};

View File

@@ -0,0 +1,263 @@
'use strict';
const fs = require('fs');
const path = require('path');
const provenance = require('./provenance');
const tracker = require('./tracker');
const versioning = require('./versioning');
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const PENDING_AMENDMENT_STATUSES = Object.freeze(new Set(['pending', 'proposed', 'queued', 'open']));
function roundRate(value) {
if (value === null) {
return null;
}
return Math.round(value * 10000) / 10000;
}
function formatRate(value) {
if (value === null) {
return 'n/a';
}
return `${Math.round(value * 100)}%`;
}
function summarizeHealthReport(report) {
const totalSkills = report.skills.length;
const decliningSkills = report.skills.filter(skill => skill.declining).length;
const healthySkills = totalSkills - decliningSkills;
return {
total_skills: totalSkills,
healthy_skills: healthySkills,
declining_skills: decliningSkills,
};
}
function listSkillsInRoot(rootPath) {
if (!rootPath || !fs.existsSync(rootPath)) {
return [];
}
return fs.readdirSync(rootPath, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => ({
skill_id: entry.name,
skill_dir: path.join(rootPath, entry.name),
}))
.filter(entry => fs.existsSync(path.join(entry.skill_dir, 'SKILL.md')));
}
function discoverSkills(options = {}) {
const roots = provenance.getSkillRoots(options);
const discoveredSkills = [
...listSkillsInRoot(options.skillsRoot || roots.curated).map(skill => ({
...skill,
skill_type: provenance.SKILL_TYPES.CURATED,
})),
...listSkillsInRoot(options.learnedRoot || roots.learned).map(skill => ({
...skill,
skill_type: provenance.SKILL_TYPES.LEARNED,
})),
...listSkillsInRoot(options.importedRoot || roots.imported).map(skill => ({
...skill,
skill_type: provenance.SKILL_TYPES.IMPORTED,
})),
];
return discoveredSkills.reduce((skillsById, skill) => {
if (!skillsById.has(skill.skill_id)) {
skillsById.set(skill.skill_id, skill);
}
return skillsById;
}, new Map());
}
function calculateSuccessRate(records) {
if (records.length === 0) {
return null;
}
const successfulRecords = records.filter(record => record.outcome === 'success').length;
return roundRate(successfulRecords / records.length);
}
function filterRecordsWithinDays(records, nowMs, days) {
const cutoff = nowMs - (days * DAY_IN_MS);
return records.filter(record => {
const recordedAtMs = Date.parse(record.recorded_at);
return !Number.isNaN(recordedAtMs) && recordedAtMs >= cutoff && recordedAtMs <= nowMs;
});
}
function getFailureTrend(successRate7d, successRate30d, warnThreshold) {
if (successRate7d === null || successRate30d === null) {
return 'stable';
}
const delta = roundRate(successRate7d - successRate30d);
if (delta <= (-1 * warnThreshold)) {
return 'worsening';
}
if (delta >= warnThreshold) {
return 'improving';
}
return 'stable';
}
function countPendingAmendments(skillDir) {
if (!skillDir) {
return 0;
}
return versioning.getEvolutionLog(skillDir, 'amendments')
.filter(entry => {
if (typeof entry.status === 'string') {
return PENDING_AMENDMENT_STATUSES.has(entry.status);
}
return entry.event === 'proposal';
})
.length;
}
function getLastRun(records) {
if (records.length === 0) {
return null;
}
return records
.map(record => ({
timestamp: record.recorded_at,
timeMs: Date.parse(record.recorded_at),
}))
.filter(entry => !Number.isNaN(entry.timeMs))
.sort((left, right) => left.timeMs - right.timeMs)
.at(-1)?.timestamp || null;
}
function collectSkillHealth(options = {}) {
const now = options.now || new Date().toISOString();
const nowMs = Date.parse(now);
if (Number.isNaN(nowMs)) {
throw new Error(`Invalid now timestamp: ${now}`);
}
const warnThreshold = typeof options.warnThreshold === 'number'
? options.warnThreshold
: Number(options.warnThreshold || 0.1);
if (!Number.isFinite(warnThreshold) || warnThreshold < 0) {
throw new Error(`Invalid warn threshold: ${options.warnThreshold}`);
}
const records = tracker.readSkillExecutionRecords(options);
const skillsById = discoverSkills(options);
const recordsBySkill = records.reduce((groupedRecords, record) => {
if (!groupedRecords.has(record.skill_id)) {
groupedRecords.set(record.skill_id, []);
}
groupedRecords.get(record.skill_id).push(record);
return groupedRecords;
}, new Map());
for (const skillId of recordsBySkill.keys()) {
if (!skillsById.has(skillId)) {
skillsById.set(skillId, {
skill_id: skillId,
skill_dir: null,
skill_type: provenance.SKILL_TYPES.UNKNOWN,
});
}
}
const skills = Array.from(skillsById.values())
.sort((left, right) => left.skill_id.localeCompare(right.skill_id))
.map(skill => {
const skillRecords = recordsBySkill.get(skill.skill_id) || [];
const records7d = filterRecordsWithinDays(skillRecords, nowMs, 7);
const records30d = filterRecordsWithinDays(skillRecords, nowMs, 30);
const successRate7d = calculateSuccessRate(records7d);
const successRate30d = calculateSuccessRate(records30d);
const currentVersionNumber = skill.skill_dir ? versioning.getCurrentVersion(skill.skill_dir) : 0;
const failureTrend = getFailureTrend(successRate7d, successRate30d, warnThreshold);
return {
skill_id: skill.skill_id,
skill_type: skill.skill_type,
current_version: currentVersionNumber > 0 ? `v${currentVersionNumber}` : null,
pending_amendments: countPendingAmendments(skill.skill_dir),
success_rate_7d: successRate7d,
success_rate_30d: successRate30d,
failure_trend: failureTrend,
declining: failureTrend === 'worsening',
last_run: getLastRun(skillRecords),
run_count_7d: records7d.length,
run_count_30d: records30d.length,
};
});
return {
generated_at: now,
warn_threshold: warnThreshold,
skills,
};
}
function formatHealthReport(report, options = {}) {
if (options.json) {
return `${JSON.stringify(report, null, 2)}\n`;
}
const summary = summarizeHealthReport(report);
if (!report.skills.length) {
return [
'ECC skill health',
`Generated: ${report.generated_at}`,
'',
'No skill execution records found.',
'',
].join('\n');
}
const lines = [
'ECC skill health',
`Generated: ${report.generated_at}`,
`Skills: ${summary.total_skills} total, ${summary.healthy_skills} healthy, ${summary.declining_skills} declining`,
'',
'skill version 7d 30d trend pending last run',
'--------------------------------------------------------------------------',
];
for (const skill of report.skills) {
const statusLabel = skill.declining ? '!' : ' ';
lines.push([
`${statusLabel}${skill.skill_id}`.padEnd(16),
String(skill.current_version || '-').padEnd(9),
formatRate(skill.success_rate_7d).padEnd(6),
formatRate(skill.success_rate_30d).padEnd(6),
skill.failure_trend.padEnd(11),
String(skill.pending_amendments).padEnd(9),
skill.last_run || '-',
].join(' '));
}
return `${lines.join('\n')}\n`;
}
module.exports = {
PENDING_AMENDMENT_STATUSES,
calculateSuccessRate,
collectSkillHealth,
discoverSkills,
filterRecordsWithinDays,
formatHealthReport,
summarizeHealthReport,
};

View File

@@ -0,0 +1,20 @@
'use strict';
const provenance = require('./provenance');
const versioning = require('./versioning');
const tracker = require('./tracker');
const health = require('./health');
const dashboard = require('./dashboard');
module.exports = {
...provenance,
...versioning,
...tracker,
...health,
...dashboard,
provenance,
versioning,
tracker,
health,
dashboard,
};

View File

@@ -0,0 +1,187 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ensureDir } = require('../utils');
const PROVENANCE_FILE_NAME = '.provenance.json';
const SKILL_TYPES = Object.freeze({
CURATED: 'curated',
LEARNED: 'learned',
IMPORTED: 'imported',
UNKNOWN: 'unknown',
});
function resolveRepoRoot(repoRoot) {
if (repoRoot) {
return path.resolve(repoRoot);
}
return path.resolve(__dirname, '..', '..', '..');
}
function resolveHomeDir(homeDir) {
return homeDir ? path.resolve(homeDir) : os.homedir();
}
function normalizeSkillDir(skillPath) {
if (!skillPath || typeof skillPath !== 'string') {
throw new Error('skillPath is required');
}
const resolvedPath = path.resolve(skillPath);
if (path.basename(resolvedPath) === 'SKILL.md') {
return path.dirname(resolvedPath);
}
return resolvedPath;
}
function isWithinRoot(targetPath, rootPath) {
const relativePath = path.relative(rootPath, targetPath);
return relativePath === '' || (
!relativePath.startsWith('..')
&& !path.isAbsolute(relativePath)
);
}
function getSkillRoots(options = {}) {
const repoRoot = resolveRepoRoot(options.repoRoot);
const homeDir = resolveHomeDir(options.homeDir);
return {
curated: path.join(repoRoot, 'skills'),
learned: path.join(homeDir, '.claude', 'skills', 'learned'),
imported: path.join(homeDir, '.claude', 'skills', 'imported'),
};
}
function classifySkillPath(skillPath, options = {}) {
const skillDir = normalizeSkillDir(skillPath);
const roots = getSkillRoots(options);
if (isWithinRoot(skillDir, roots.curated)) {
return SKILL_TYPES.CURATED;
}
if (isWithinRoot(skillDir, roots.learned)) {
return SKILL_TYPES.LEARNED;
}
if (isWithinRoot(skillDir, roots.imported)) {
return SKILL_TYPES.IMPORTED;
}
return SKILL_TYPES.UNKNOWN;
}
function requiresProvenance(skillPath, options = {}) {
const skillType = classifySkillPath(skillPath, options);
return skillType === SKILL_TYPES.LEARNED || skillType === SKILL_TYPES.IMPORTED;
}
function getProvenancePath(skillPath) {
return path.join(normalizeSkillDir(skillPath), PROVENANCE_FILE_NAME);
}
function isIsoTimestamp(value) {
if (typeof value !== 'string' || value.trim().length === 0) {
return false;
}
const timestamp = Date.parse(value);
return !Number.isNaN(timestamp);
}
function validateProvenance(record) {
const errors = [];
if (!record || typeof record !== 'object' || Array.isArray(record)) {
errors.push('provenance record must be an object');
return {
valid: false,
errors,
};
}
if (typeof record.source !== 'string' || record.source.trim().length === 0) {
errors.push('source is required');
}
if (!isIsoTimestamp(record.created_at)) {
errors.push('created_at must be an ISO timestamp');
}
if (typeof record.confidence !== 'number' || Number.isNaN(record.confidence)) {
errors.push('confidence must be a number');
} else if (record.confidence < 0 || record.confidence > 1) {
errors.push('confidence must be between 0 and 1');
}
if (typeof record.author !== 'string' || record.author.trim().length === 0) {
errors.push('author is required');
}
return {
valid: errors.length === 0,
errors,
};
}
function assertValidProvenance(record) {
const validation = validateProvenance(record);
if (!validation.valid) {
throw new Error(`Invalid provenance metadata: ${validation.errors.join('; ')}`);
}
}
function readProvenance(skillPath, options = {}) {
const skillDir = normalizeSkillDir(skillPath);
const provenancePath = getProvenancePath(skillDir);
const provenanceRequired = options.required === true || requiresProvenance(skillDir, options);
if (!fs.existsSync(provenancePath)) {
if (provenanceRequired) {
throw new Error(`Missing provenance metadata for ${skillDir}`);
}
return null;
}
const record = JSON.parse(fs.readFileSync(provenancePath, 'utf8'));
assertValidProvenance(record);
return record;
}
function writeProvenance(skillPath, record, options = {}) {
const skillDir = normalizeSkillDir(skillPath);
if (!requiresProvenance(skillDir, options)) {
throw new Error(`Provenance metadata is only required for learned or imported skills: ${skillDir}`);
}
assertValidProvenance(record);
const provenancePath = getProvenancePath(skillDir);
ensureDir(skillDir);
fs.writeFileSync(provenancePath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
return {
path: provenancePath,
record: { ...record },
};
}
module.exports = {
PROVENANCE_FILE_NAME,
SKILL_TYPES,
classifySkillPath,
getProvenancePath,
getSkillRoots,
readProvenance,
requiresProvenance,
validateProvenance,
writeProvenance,
};

View File

@@ -0,0 +1,146 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { appendFile } = require('../utils');
const VALID_OUTCOMES = new Set(['success', 'failure', 'partial']);
const VALID_FEEDBACK = new Set(['accepted', 'corrected', 'rejected']);
function resolveHomeDir(homeDir) {
return homeDir ? path.resolve(homeDir) : os.homedir();
}
function getRunsFilePath(options = {}) {
if (options.runsFilePath) {
return path.resolve(options.runsFilePath);
}
return path.join(resolveHomeDir(options.homeDir), '.claude', 'state', 'skill-runs.jsonl');
}
function toNullableNumber(value, fieldName) {
if (value === null || typeof value === 'undefined') {
return null;
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
throw new Error(`${fieldName} must be a number`);
}
return numericValue;
}
function normalizeExecutionRecord(input, options = {}) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('skill execution payload must be an object');
}
const skillId = input.skill_id || input.skillId;
const skillVersion = input.skill_version || input.skillVersion;
const taskDescription = input.task_description || input.task_attempted || input.taskAttempted;
const outcome = input.outcome;
const recordedAt = input.recorded_at || options.now || new Date().toISOString();
const userFeedback = input.user_feedback || input.userFeedback || null;
if (typeof skillId !== 'string' || skillId.trim().length === 0) {
throw new Error('skill_id is required');
}
if (typeof skillVersion !== 'string' || skillVersion.trim().length === 0) {
throw new Error('skill_version is required');
}
if (typeof taskDescription !== 'string' || taskDescription.trim().length === 0) {
throw new Error('task_description is required');
}
if (!VALID_OUTCOMES.has(outcome)) {
throw new Error('outcome must be one of success, failure, or partial');
}
if (userFeedback !== null && !VALID_FEEDBACK.has(userFeedback)) {
throw new Error('user_feedback must be accepted, corrected, rejected, or null');
}
if (Number.isNaN(Date.parse(recordedAt))) {
throw new Error('recorded_at must be an ISO timestamp');
}
return {
skill_id: skillId,
skill_version: skillVersion,
task_description: taskDescription,
outcome,
failure_reason: input.failure_reason || input.failureReason || null,
tokens_used: toNullableNumber(input.tokens_used ?? input.tokensUsed, 'tokens_used'),
duration_ms: toNullableNumber(input.duration_ms ?? input.durationMs, 'duration_ms'),
user_feedback: userFeedback,
recorded_at: recordedAt,
};
}
function readJsonl(filePath) {
if (!fs.existsSync(filePath)) {
return [];
}
return fs.readFileSync(filePath, 'utf8')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.reduce((rows, line) => {
try {
rows.push(JSON.parse(line));
} catch {
// Ignore malformed rows so analytics remain best-effort.
}
return rows;
}, []);
}
function recordSkillExecution(input, options = {}) {
const record = normalizeExecutionRecord(input, options);
if (options.stateStore && typeof options.stateStore.recordSkillExecution === 'function') {
try {
const result = options.stateStore.recordSkillExecution(record);
return {
storage: 'state-store',
record,
result,
};
} catch {
// Fall back to JSONL until the formal state-store exists on this branch.
}
}
const runsFilePath = getRunsFilePath(options);
appendFile(runsFilePath, `${JSON.stringify(record)}\n`);
return {
storage: 'jsonl',
path: runsFilePath,
record,
};
}
function readSkillExecutionRecords(options = {}) {
if (options.stateStore && typeof options.stateStore.listSkillExecutionRecords === 'function') {
return options.stateStore.listSkillExecutionRecords();
}
return readJsonl(getRunsFilePath(options));
}
module.exports = {
VALID_FEEDBACK,
VALID_OUTCOMES,
getRunsFilePath,
normalizeExecutionRecord,
readSkillExecutionRecords,
recordSkillExecution,
};

View File

@@ -0,0 +1,237 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { appendFile, ensureDir } = require('../utils');
const VERSION_DIRECTORY_NAME = '.versions';
const EVOLUTION_DIRECTORY_NAME = '.evolution';
const EVOLUTION_LOG_TYPES = Object.freeze([
'observations',
'inspections',
'amendments',
]);
function normalizeSkillDir(skillPath) {
if (!skillPath || typeof skillPath !== 'string') {
throw new Error('skillPath is required');
}
const resolvedPath = path.resolve(skillPath);
if (path.basename(resolvedPath) === 'SKILL.md') {
return path.dirname(resolvedPath);
}
return resolvedPath;
}
function getSkillFilePath(skillPath) {
return path.join(normalizeSkillDir(skillPath), 'SKILL.md');
}
function ensureSkillExists(skillPath) {
const skillFilePath = getSkillFilePath(skillPath);
if (!fs.existsSync(skillFilePath)) {
throw new Error(`Skill file not found: ${skillFilePath}`);
}
return skillFilePath;
}
function getVersionsDir(skillPath) {
return path.join(normalizeSkillDir(skillPath), VERSION_DIRECTORY_NAME);
}
function getEvolutionDir(skillPath) {
return path.join(normalizeSkillDir(skillPath), EVOLUTION_DIRECTORY_NAME);
}
function getEvolutionLogPath(skillPath, logType) {
if (!EVOLUTION_LOG_TYPES.includes(logType)) {
throw new Error(`Unknown evolution log type: ${logType}`);
}
return path.join(getEvolutionDir(skillPath), `${logType}.jsonl`);
}
function ensureSkillVersioning(skillPath) {
ensureSkillExists(skillPath);
const versionsDir = getVersionsDir(skillPath);
const evolutionDir = getEvolutionDir(skillPath);
ensureDir(versionsDir);
ensureDir(evolutionDir);
for (const logType of EVOLUTION_LOG_TYPES) {
const logPath = getEvolutionLogPath(skillPath, logType);
if (!fs.existsSync(logPath)) {
fs.writeFileSync(logPath, '', 'utf8');
}
}
return {
versionsDir,
evolutionDir,
};
}
function parseVersionNumber(fileName) {
const match = /^v(\d+)\.md$/.exec(fileName);
if (!match) {
return null;
}
return Number(match[1]);
}
function listVersions(skillPath) {
const versionsDir = getVersionsDir(skillPath);
if (!fs.existsSync(versionsDir)) {
return [];
}
return fs.readdirSync(versionsDir)
.map(fileName => {
const version = parseVersionNumber(fileName);
if (version === null) {
return null;
}
const filePath = path.join(versionsDir, fileName);
const stats = fs.statSync(filePath);
return {
version,
path: filePath,
created_at: stats.mtime.toISOString(),
};
})
.filter(Boolean)
.sort((left, right) => left.version - right.version);
}
function getCurrentVersion(skillPath) {
const skillFilePath = getSkillFilePath(skillPath);
if (!fs.existsSync(skillFilePath)) {
return 0;
}
const versions = listVersions(skillPath);
if (versions.length === 0) {
return 1;
}
return versions[versions.length - 1].version;
}
function appendEvolutionRecord(skillPath, logType, record) {
ensureSkillVersioning(skillPath);
appendFile(getEvolutionLogPath(skillPath, logType), `${JSON.stringify(record)}\n`);
return { ...record };
}
function readJsonl(filePath) {
if (!fs.existsSync(filePath)) {
return [];
}
return fs.readFileSync(filePath, 'utf8')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.reduce((rows, line) => {
try {
rows.push(JSON.parse(line));
} catch {
// Ignore malformed rows so the log remains append-only and resilient.
}
return rows;
}, []);
}
function getEvolutionLog(skillPath, logType) {
return readJsonl(getEvolutionLogPath(skillPath, logType));
}
function createVersion(skillPath, options = {}) {
const skillFilePath = ensureSkillExists(skillPath);
ensureSkillVersioning(skillPath);
const versions = listVersions(skillPath);
const nextVersion = versions.length === 0 ? 1 : versions[versions.length - 1].version + 1;
const snapshotPath = path.join(getVersionsDir(skillPath), `v${nextVersion}.md`);
const skillContent = fs.readFileSync(skillFilePath, 'utf8');
const createdAt = options.timestamp || new Date().toISOString();
fs.writeFileSync(snapshotPath, skillContent, 'utf8');
appendEvolutionRecord(skillPath, 'amendments', {
event: 'snapshot',
version: nextVersion,
reason: options.reason || null,
author: options.author || null,
status: 'applied',
created_at: createdAt,
});
return {
version: nextVersion,
path: snapshotPath,
created_at: createdAt,
};
}
function rollbackTo(skillPath, targetVersion, options = {}) {
const normalizedTargetVersion = Number(targetVersion);
if (!Number.isInteger(normalizedTargetVersion) || normalizedTargetVersion <= 0) {
throw new Error(`Invalid target version: ${targetVersion}`);
}
ensureSkillExists(skillPath);
ensureSkillVersioning(skillPath);
const targetPath = path.join(getVersionsDir(skillPath), `v${normalizedTargetVersion}.md`);
if (!fs.existsSync(targetPath)) {
throw new Error(`Version not found: v${normalizedTargetVersion}`);
}
const currentVersion = getCurrentVersion(skillPath);
const targetContent = fs.readFileSync(targetPath, 'utf8');
fs.writeFileSync(getSkillFilePath(skillPath), targetContent, 'utf8');
const createdVersion = createVersion(skillPath, {
timestamp: options.timestamp,
reason: options.reason || `rollback to v${normalizedTargetVersion}`,
author: options.author || null,
});
appendEvolutionRecord(skillPath, 'amendments', {
event: 'rollback',
version: createdVersion.version,
source_version: currentVersion,
target_version: normalizedTargetVersion,
reason: options.reason || null,
author: options.author || null,
status: 'applied',
created_at: options.timestamp || new Date().toISOString(),
});
return createdVersion;
}
module.exports = {
EVOLUTION_DIRECTORY_NAME,
EVOLUTION_LOG_TYPES,
VERSION_DIRECTORY_NAME,
appendEvolutionRecord,
createVersion,
ensureSkillVersioning,
getCurrentVersion,
getEvolutionDir,
getEvolutionLog,
getEvolutionLogPath,
getVersionsDir,
listVersions,
rollbackTo,
};

View File

@@ -0,0 +1,89 @@
'use strict';
const { buildSkillHealthReport } = require('./health');
const AMENDMENT_SCHEMA_VERSION = 'ecc.skill-amendment-proposal.v1';
function createProposalId(skillId) {
return `amend-${skillId}-${Date.now()}`;
}
function summarizePatchPreview(skillId, health) {
const lines = [
'## Failure-Driven Amendments',
'',
`- Focus skill routing for \`${skillId}\` when tasks match the proven success cases.`,
];
if (health.recurringErrors[0]) {
lines.push(`- Add explicit guardrails for recurring failure: ${health.recurringErrors[0].error}.`);
}
if (health.recurringTasks[0]) {
lines.push(`- Add an example workflow for task pattern: ${health.recurringTasks[0].task}.`);
}
if (health.recurringFeedback[0]) {
lines.push(`- Address repeated user feedback: ${health.recurringFeedback[0].feedback}.`);
}
lines.push('- Add a verification checklist before declaring the skill output complete.');
return lines.join('\n');
}
function proposeSkillAmendment(skillId, records, options = {}) {
const report = buildSkillHealthReport(records, {
...options,
skillId,
minFailureCount: options.minFailureCount || 1
});
const [health] = report.skills;
if (!health || health.failures === 0) {
return {
schemaVersion: AMENDMENT_SCHEMA_VERSION,
skill: {
id: skillId,
path: null
},
status: 'insufficient-evidence',
rationale: ['No failed observations were available for this skill.'],
patch: null
};
}
const preview = summarizePatchPreview(skillId, health);
return {
schemaVersion: AMENDMENT_SCHEMA_VERSION,
proposalId: createProposalId(skillId),
generatedAt: new Date().toISOString(),
status: 'proposed',
skill: {
id: skillId,
path: health.skill.path || null
},
evidence: {
totalRuns: health.totalRuns,
failures: health.failures,
successRate: health.successRate,
recurringErrors: health.recurringErrors,
recurringTasks: health.recurringTasks,
recurringFeedback: health.recurringFeedback
},
rationale: [
'Proposals are generated from repeated failed runs rather than a single anecdotal error.',
'The suggested patch is additive so the original SKILL.md intent remains auditable.'
],
patch: {
format: 'markdown-fragment',
targetPath: health.skill.path || `skills/${skillId}/SKILL.md`,
preview
}
};
}
module.exports = {
AMENDMENT_SCHEMA_VERSION,
proposeSkillAmendment
};

View File

@@ -0,0 +1,59 @@
'use strict';
const EVALUATION_SCHEMA_VERSION = 'ecc.skill-evaluation.v1';
function roundRate(value) {
return Math.round(value * 1000) / 1000;
}
function summarize(records) {
const runs = records.length;
const successes = records.filter(record => record.outcome && record.outcome.success).length;
const failures = runs - successes;
return {
runs,
successes,
failures,
successRate: runs > 0 ? roundRate(successes / runs) : 0
};
}
function buildSkillEvaluationScaffold(skillId, records, options = {}) {
const minimumRunsPerVariant = options.minimumRunsPerVariant || 2;
const amendmentId = options.amendmentId || null;
const filtered = records.filter(record => record.skill && record.skill.id === skillId);
const baseline = filtered.filter(record => !record.run || record.run.variant !== 'amended');
const amended = filtered.filter(record => record.run && record.run.variant === 'amended')
.filter(record => !amendmentId || record.run.amendmentId === amendmentId);
const baselineSummary = summarize(baseline);
const amendedSummary = summarize(amended);
const delta = {
successRate: roundRate(amendedSummary.successRate - baselineSummary.successRate),
failures: amendedSummary.failures - baselineSummary.failures
};
let recommendation = 'insufficient-data';
if (baselineSummary.runs >= minimumRunsPerVariant && amendedSummary.runs >= minimumRunsPerVariant) {
recommendation = delta.successRate > 0 ? 'promote-amendment' : 'keep-baseline';
}
return {
schemaVersion: EVALUATION_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
skillId,
amendmentId,
gate: {
minimumRunsPerVariant
},
baseline: baselineSummary,
amended: amendedSummary,
delta,
recommendation
};
}
module.exports = {
EVALUATION_SCHEMA_VERSION,
buildSkillEvaluationScaffold
};

View File

@@ -0,0 +1,118 @@
'use strict';
const HEALTH_SCHEMA_VERSION = 'ecc.skill-health.v1';
function roundRate(value) {
return Math.round(value * 1000) / 1000;
}
function rankCounts(values) {
return Array.from(values.entries())
.map(([value, count]) => ({ value, count }))
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value));
}
function summarizeVariantRuns(records) {
return records.reduce((accumulator, record) => {
const key = record.run && record.run.variant ? record.run.variant : 'baseline';
if (!accumulator[key]) {
accumulator[key] = { runs: 0, successes: 0, failures: 0 };
}
accumulator[key].runs += 1;
if (record.outcome && record.outcome.success) {
accumulator[key].successes += 1;
} else {
accumulator[key].failures += 1;
}
return accumulator;
}, {});
}
function deriveSkillStatus(skillSummary, options = {}) {
const minFailureCount = options.minFailureCount || 2;
if (skillSummary.failures >= minFailureCount) {
return 'failing';
}
if (skillSummary.failures > 0) {
return 'watch';
}
return 'healthy';
}
function buildSkillHealthReport(records, options = {}) {
const filterSkillId = options.skillId || null;
const filtered = filterSkillId
? records.filter(record => record.skill && record.skill.id === filterSkillId)
: records.slice();
const grouped = filtered.reduce((accumulator, record) => {
const skillId = record.skill.id;
if (!accumulator.has(skillId)) {
accumulator.set(skillId, []);
}
accumulator.get(skillId).push(record);
return accumulator;
}, new Map());
const skills = Array.from(grouped.entries())
.map(([skillId, skillRecords]) => {
const successes = skillRecords.filter(record => record.outcome && record.outcome.success).length;
const failures = skillRecords.length - successes;
const recurringErrors = new Map();
const recurringTasks = new Map();
const recurringFeedback = new Map();
skillRecords.forEach(record => {
if (!record.outcome || record.outcome.success) {
return;
}
if (record.outcome.error) {
recurringErrors.set(record.outcome.error, (recurringErrors.get(record.outcome.error) || 0) + 1);
}
if (record.task) {
recurringTasks.set(record.task, (recurringTasks.get(record.task) || 0) + 1);
}
if (record.outcome.feedback) {
recurringFeedback.set(record.outcome.feedback, (recurringFeedback.get(record.outcome.feedback) || 0) + 1);
}
});
const summary = {
skill: {
id: skillId,
path: skillRecords[0].skill.path || null
},
totalRuns: skillRecords.length,
successes,
failures,
successRate: skillRecords.length > 0 ? roundRate(successes / skillRecords.length) : 0,
status: 'healthy',
recurringErrors: rankCounts(recurringErrors).map(entry => ({ error: entry.value, count: entry.count })),
recurringTasks: rankCounts(recurringTasks).map(entry => ({ task: entry.value, count: entry.count })),
recurringFeedback: rankCounts(recurringFeedback).map(entry => ({ feedback: entry.value, count: entry.count })),
variants: summarizeVariantRuns(skillRecords)
};
summary.status = deriveSkillStatus(summary, options);
return summary;
})
.sort((left, right) => right.failures - left.failures || left.skill.id.localeCompare(right.skill.id));
return {
schemaVersion: HEALTH_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
totalObservations: filtered.length,
skillCount: skills.length,
skills
};
}
module.exports = {
HEALTH_SCHEMA_VERSION,
buildSkillHealthReport
};

View File

@@ -0,0 +1,108 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const OBSERVATION_SCHEMA_VERSION = 'ecc.skill-observation.v1';
function resolveProjectRoot(options = {}) {
return path.resolve(options.projectRoot || options.cwd || process.cwd());
}
function getSkillTelemetryRoot(options = {}) {
return path.join(resolveProjectRoot(options), '.claude', 'ecc', 'skills');
}
function getSkillObservationsPath(options = {}) {
return path.join(getSkillTelemetryRoot(options), 'observations.jsonl');
}
function ensureString(value, label) {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`${label} must be a non-empty string`);
}
return value.trim();
}
function createObservationId() {
return `obs-${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
}
function createSkillObservation(input) {
const task = ensureString(input.task, 'task');
const skillId = ensureString(input.skill && input.skill.id, 'skill.id');
const skillPath = typeof input.skill.path === 'string' && input.skill.path.trim().length > 0
? input.skill.path.trim()
: null;
const success = Boolean(input.success);
const error = input.error == null ? null : String(input.error);
const feedback = input.feedback == null ? null : String(input.feedback);
const variant = typeof input.variant === 'string' && input.variant.trim().length > 0
? input.variant.trim()
: 'baseline';
return {
schemaVersion: OBSERVATION_SCHEMA_VERSION,
observationId: typeof input.observationId === 'string' && input.observationId.length > 0
? input.observationId
: createObservationId(),
timestamp: typeof input.timestamp === 'string' && input.timestamp.length > 0
? input.timestamp
: new Date().toISOString(),
task,
skill: {
id: skillId,
path: skillPath
},
outcome: {
success,
status: success ? 'success' : 'failure',
error,
feedback
},
run: {
variant,
amendmentId: input.amendmentId || null,
sessionId: input.sessionId || null,
source: input.source || 'manual'
}
};
}
function appendSkillObservation(observation, options = {}) {
const outputPath = getSkillObservationsPath(options);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.appendFileSync(outputPath, `${JSON.stringify(observation)}${os.EOL}`, 'utf8');
return outputPath;
}
function readSkillObservations(options = {}) {
const observationPath = path.resolve(options.observationsPath || getSkillObservationsPath(options));
if (!fs.existsSync(observationPath)) {
return [];
}
return fs.readFileSync(observationPath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(record => record && record.schemaVersion === OBSERVATION_SCHEMA_VERSION);
}
module.exports = {
OBSERVATION_SCHEMA_VERSION,
appendSkillObservation,
createSkillObservation,
getSkillObservationsPath,
getSkillTelemetryRoot,
readSkillObservations,
resolveProjectRoot
};

View File

@@ -0,0 +1,191 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const initSqlJs = require('sql.js');
const { applyMigrations, getAppliedMigrations } = require('./migrations');
const { createQueryApi } = require('./queries');
const { assertValidEntity, validateEntity } = require('./schema');
const DEFAULT_STATE_STORE_RELATIVE_PATH = path.join('.claude', 'ecc', 'state.db');
function resolveStateStorePath(options = {}) {
if (options.dbPath) {
if (options.dbPath === ':memory:') {
return options.dbPath;
}
return path.resolve(options.dbPath);
}
const homeDir = options.homeDir || process.env.HOME || os.homedir();
return path.join(homeDir, DEFAULT_STATE_STORE_RELATIVE_PATH);
}
/**
* Wraps a sql.js Database with a better-sqlite3-compatible API surface so
* that the rest of the state-store code (migrations.js, queries.js) can
* operate without knowing which driver is in use.
*
* IMPORTANT: sql.js db.export() implicitly ends any active transaction, so
* we must defer all disk writes until after the transaction commits.
*/
function wrapSqlJsDatabase(rawDb, dbPath) {
let inTransaction = false;
function saveToDisk() {
if (dbPath === ':memory:' || inTransaction) {
return;
}
const data = rawDb.export();
const buffer = Buffer.from(data);
fs.writeFileSync(dbPath, buffer);
}
const db = {
exec(sql) {
rawDb.run(sql);
saveToDisk();
},
pragma(pragmaStr) {
try {
rawDb.run(`PRAGMA ${pragmaStr}`);
} catch (_error) {
// Ignore unsupported pragmas (e.g. WAL for in-memory databases).
}
},
prepare(sql) {
return {
all(...positionalArgs) {
const stmt = rawDb.prepare(sql);
if (positionalArgs.length === 1 && typeof positionalArgs[0] !== 'object') {
stmt.bind([positionalArgs[0]]);
} else if (positionalArgs.length > 1) {
stmt.bind(positionalArgs);
}
const rows = [];
while (stmt.step()) {
rows.push(stmt.getAsObject());
}
stmt.free();
return rows;
},
get(...positionalArgs) {
const stmt = rawDb.prepare(sql);
if (positionalArgs.length === 1 && typeof positionalArgs[0] !== 'object') {
stmt.bind([positionalArgs[0]]);
} else if (positionalArgs.length > 1) {
stmt.bind(positionalArgs);
}
let row = null;
if (stmt.step()) {
row = stmt.getAsObject();
}
stmt.free();
return row;
},
run(namedParams) {
const stmt = rawDb.prepare(sql);
if (namedParams && typeof namedParams === 'object' && !Array.isArray(namedParams)) {
const sqlJsParams = {};
for (const [key, value] of Object.entries(namedParams)) {
sqlJsParams[`@${key}`] = value === undefined ? null : value;
}
stmt.bind(sqlJsParams);
}
stmt.step();
stmt.free();
saveToDisk();
},
};
},
transaction(fn) {
return (...args) => {
rawDb.run('BEGIN');
inTransaction = true;
try {
const result = fn(...args);
rawDb.run('COMMIT');
inTransaction = false;
saveToDisk();
return result;
} catch (error) {
try {
rawDb.run('ROLLBACK');
} catch (_rollbackError) {
// Transaction may already be rolled back.
}
inTransaction = false;
throw error;
}
};
},
close() {
saveToDisk();
rawDb.close();
},
};
return db;
}
async function openDatabase(SQL, dbPath) {
if (dbPath !== ':memory:') {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
let rawDb;
if (dbPath !== ':memory:' && fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
rawDb = new SQL.Database(fileBuffer);
} else {
rawDb = new SQL.Database();
}
const db = wrapSqlJsDatabase(rawDb, dbPath);
db.pragma('foreign_keys = ON');
try {
db.pragma('journal_mode = WAL');
} catch (_error) {
// Some SQLite environments reject WAL for in-memory or readonly contexts.
}
return db;
}
async function createStateStore(options = {}) {
const dbPath = resolveStateStorePath(options);
const SQL = await initSqlJs();
const db = await openDatabase(SQL, dbPath);
const appliedMigrations = applyMigrations(db);
const queryApi = createQueryApi(db);
return {
dbPath,
close() {
db.close();
},
getAppliedMigrations() {
return getAppliedMigrations(db);
},
validateEntity,
assertValidEntity,
...queryApi,
_database: db,
_migrations: appliedMigrations,
};
}
module.exports = {
DEFAULT_STATE_STORE_RELATIVE_PATH,
createStateStore,
resolveStateStorePath,
};

View File

@@ -0,0 +1,178 @@
'use strict';
const INITIAL_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
adapter_id TEXT NOT NULL,
harness TEXT NOT NULL,
state TEXT NOT NULL,
repo_root TEXT,
started_at TEXT,
ended_at TEXT,
snapshot TEXT NOT NULL CHECK (json_valid(snapshot))
);
CREATE INDEX IF NOT EXISTS idx_sessions_state_started_at
ON sessions (state, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_started_at
ON sessions (started_at DESC);
CREATE TABLE IF NOT EXISTS skill_runs (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL,
skill_version TEXT NOT NULL,
session_id TEXT NOT NULL,
task_description TEXT NOT NULL,
outcome TEXT NOT NULL,
failure_reason TEXT,
tokens_used INTEGER,
duration_ms INTEGER,
user_feedback TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_skill_runs_session_id_created_at
ON skill_runs (session_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_runs_created_at
ON skill_runs (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_runs_outcome_created_at
ON skill_runs (outcome, created_at DESC);
CREATE TABLE IF NOT EXISTS skill_versions (
skill_id TEXT NOT NULL,
version TEXT NOT NULL,
content_hash TEXT NOT NULL,
amendment_reason TEXT,
promoted_at TEXT,
rolled_back_at TEXT,
PRIMARY KEY (skill_id, version)
);
CREATE INDEX IF NOT EXISTS idx_skill_versions_promoted_at
ON skill_versions (promoted_at DESC);
CREATE TABLE IF NOT EXISTS decisions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
title TEXT NOT NULL,
rationale TEXT NOT NULL,
alternatives TEXT NOT NULL CHECK (json_valid(alternatives)),
supersedes TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
FOREIGN KEY (supersedes) REFERENCES decisions (id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_decisions_session_id_created_at
ON decisions (session_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_decisions_status_created_at
ON decisions (status, created_at DESC);
CREATE TABLE IF NOT EXISTS install_state (
target_id TEXT NOT NULL,
target_root TEXT NOT NULL,
profile TEXT,
modules TEXT NOT NULL CHECK (json_valid(modules)),
operations TEXT NOT NULL CHECK (json_valid(operations)),
installed_at TEXT NOT NULL,
source_version TEXT,
PRIMARY KEY (target_id, target_root)
);
CREATE INDEX IF NOT EXISTS idx_install_state_installed_at
ON install_state (installed_at DESC);
CREATE TABLE IF NOT EXISTS governance_events (
id TEXT PRIMARY KEY,
session_id TEXT,
event_type TEXT NOT NULL,
payload TEXT NOT NULL CHECK (json_valid(payload)),
resolved_at TEXT,
resolution TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_governance_events_resolved_at_created_at
ON governance_events (resolved_at, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_governance_events_session_id_created_at
ON governance_events (session_id, created_at DESC);
`;
const MIGRATIONS = [
{
version: 1,
name: '001_initial_state_store',
sql: INITIAL_SCHEMA_SQL,
},
];
function ensureMigrationTable(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
`);
}
function getAppliedMigrations(db) {
ensureMigrationTable(db);
return db
.prepare(`
SELECT version, name, applied_at
FROM schema_migrations
ORDER BY version ASC
`)
.all()
.map(row => ({
version: row.version,
name: row.name,
appliedAt: row.applied_at,
}));
}
function applyMigrations(db) {
ensureMigrationTable(db);
const appliedVersions = new Set(
db.prepare('SELECT version FROM schema_migrations').all().map(row => row.version)
);
const insertMigration = db.prepare(`
INSERT INTO schema_migrations (version, name, applied_at)
VALUES (@version, @name, @applied_at)
`);
const applyPending = db.transaction(() => {
for (const migration of MIGRATIONS) {
if (appliedVersions.has(migration.version)) {
continue;
}
db.exec(migration.sql);
insertMigration.run({
version: migration.version,
name: migration.name,
applied_at: new Date().toISOString(),
});
}
});
applyPending();
return getAppliedMigrations(db);
}
module.exports = {
MIGRATIONS,
applyMigrations,
getAppliedMigrations,
};

View File

@@ -0,0 +1,697 @@
'use strict';
const { assertValidEntity } = require('./schema');
const ACTIVE_SESSION_STATES = ['active', 'running', 'idle'];
const SUCCESS_OUTCOMES = new Set(['success', 'succeeded', 'passed']);
const FAILURE_OUTCOMES = new Set(['failure', 'failed', 'error']);
function normalizeLimit(value, fallback) {
if (value === undefined || value === null) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid limit: ${value}`);
}
return parsed;
}
function parseJsonColumn(value, fallback) {
if (value === null || value === undefined || value === '') {
return fallback;
}
return JSON.parse(value);
}
function stringifyJson(value, label) {
try {
return JSON.stringify(value);
} catch (error) {
throw new Error(`Failed to serialize ${label}: ${error.message}`);
}
}
function mapSessionRow(row) {
const snapshot = parseJsonColumn(row.snapshot, {});
return {
id: row.id,
adapterId: row.adapter_id,
harness: row.harness,
state: row.state,
repoRoot: row.repo_root,
startedAt: row.started_at,
endedAt: row.ended_at,
snapshot,
workerCount: Array.isArray(snapshot && snapshot.workers) ? snapshot.workers.length : 0,
};
}
function mapSkillRunRow(row) {
return {
id: row.id,
skillId: row.skill_id,
skillVersion: row.skill_version,
sessionId: row.session_id,
taskDescription: row.task_description,
outcome: row.outcome,
failureReason: row.failure_reason,
tokensUsed: row.tokens_used,
durationMs: row.duration_ms,
userFeedback: row.user_feedback,
createdAt: row.created_at,
};
}
function mapSkillVersionRow(row) {
return {
skillId: row.skill_id,
version: row.version,
contentHash: row.content_hash,
amendmentReason: row.amendment_reason,
promotedAt: row.promoted_at,
rolledBackAt: row.rolled_back_at,
};
}
function mapDecisionRow(row) {
return {
id: row.id,
sessionId: row.session_id,
title: row.title,
rationale: row.rationale,
alternatives: parseJsonColumn(row.alternatives, []),
supersedes: row.supersedes,
status: row.status,
createdAt: row.created_at,
};
}
function mapInstallStateRow(row) {
const modules = parseJsonColumn(row.modules, []);
const operations = parseJsonColumn(row.operations, []);
const status = row.source_version && row.installed_at ? 'healthy' : 'warning';
return {
targetId: row.target_id,
targetRoot: row.target_root,
profile: row.profile,
modules,
operations,
installedAt: row.installed_at,
sourceVersion: row.source_version,
moduleCount: Array.isArray(modules) ? modules.length : 0,
operationCount: Array.isArray(operations) ? operations.length : 0,
status,
};
}
function mapGovernanceEventRow(row) {
return {
id: row.id,
sessionId: row.session_id,
eventType: row.event_type,
payload: parseJsonColumn(row.payload, null),
resolvedAt: row.resolved_at,
resolution: row.resolution,
createdAt: row.created_at,
};
}
function classifyOutcome(outcome) {
const normalized = String(outcome || '').toLowerCase();
if (SUCCESS_OUTCOMES.has(normalized)) {
return 'success';
}
if (FAILURE_OUTCOMES.has(normalized)) {
return 'failure';
}
return 'unknown';
}
function toPercent(numerator, denominator) {
if (denominator === 0) {
return null;
}
return Number(((numerator / denominator) * 100).toFixed(1));
}
function summarizeSkillRuns(skillRuns) {
const summary = {
totalCount: skillRuns.length,
knownCount: 0,
successCount: 0,
failureCount: 0,
unknownCount: 0,
successRate: null,
failureRate: null,
};
for (const skillRun of skillRuns) {
const classification = classifyOutcome(skillRun.outcome);
if (classification === 'success') {
summary.successCount += 1;
summary.knownCount += 1;
} else if (classification === 'failure') {
summary.failureCount += 1;
summary.knownCount += 1;
} else {
summary.unknownCount += 1;
}
}
summary.successRate = toPercent(summary.successCount, summary.knownCount);
summary.failureRate = toPercent(summary.failureCount, summary.knownCount);
return summary;
}
function summarizeInstallHealth(installations) {
if (installations.length === 0) {
return {
status: 'missing',
totalCount: 0,
healthyCount: 0,
warningCount: 0,
installations: [],
};
}
const summary = installations.reduce((result, installation) => {
if (installation.status === 'healthy') {
result.healthyCount += 1;
} else {
result.warningCount += 1;
}
return result;
}, {
totalCount: installations.length,
healthyCount: 0,
warningCount: 0,
});
return {
status: summary.warningCount > 0 ? 'warning' : 'healthy',
...summary,
installations,
};
}
function normalizeSessionInput(session) {
return {
id: session.id,
adapterId: session.adapterId,
harness: session.harness,
state: session.state,
repoRoot: session.repoRoot ?? null,
startedAt: session.startedAt ?? null,
endedAt: session.endedAt ?? null,
snapshot: session.snapshot ?? {},
};
}
function normalizeSkillRunInput(skillRun) {
return {
id: skillRun.id,
skillId: skillRun.skillId,
skillVersion: skillRun.skillVersion,
sessionId: skillRun.sessionId,
taskDescription: skillRun.taskDescription,
outcome: skillRun.outcome,
failureReason: skillRun.failureReason ?? null,
tokensUsed: skillRun.tokensUsed ?? null,
durationMs: skillRun.durationMs ?? null,
userFeedback: skillRun.userFeedback ?? null,
createdAt: skillRun.createdAt || new Date().toISOString(),
};
}
function normalizeSkillVersionInput(skillVersion) {
return {
skillId: skillVersion.skillId,
version: skillVersion.version,
contentHash: skillVersion.contentHash,
amendmentReason: skillVersion.amendmentReason ?? null,
promotedAt: skillVersion.promotedAt ?? null,
rolledBackAt: skillVersion.rolledBackAt ?? null,
};
}
function normalizeDecisionInput(decision) {
return {
id: decision.id,
sessionId: decision.sessionId,
title: decision.title,
rationale: decision.rationale,
alternatives: decision.alternatives === undefined || decision.alternatives === null
? []
: decision.alternatives,
supersedes: decision.supersedes ?? null,
status: decision.status,
createdAt: decision.createdAt || new Date().toISOString(),
};
}
function normalizeInstallStateInput(installState) {
return {
targetId: installState.targetId,
targetRoot: installState.targetRoot,
profile: installState.profile ?? null,
modules: installState.modules === undefined || installState.modules === null
? []
: installState.modules,
operations: installState.operations === undefined || installState.operations === null
? []
: installState.operations,
installedAt: installState.installedAt || new Date().toISOString(),
sourceVersion: installState.sourceVersion ?? null,
};
}
function normalizeGovernanceEventInput(governanceEvent) {
return {
id: governanceEvent.id,
sessionId: governanceEvent.sessionId ?? null,
eventType: governanceEvent.eventType,
payload: governanceEvent.payload ?? null,
resolvedAt: governanceEvent.resolvedAt ?? null,
resolution: governanceEvent.resolution ?? null,
createdAt: governanceEvent.createdAt || new Date().toISOString(),
};
}
function createQueryApi(db) {
const listRecentSessionsStatement = db.prepare(`
SELECT *
FROM sessions
ORDER BY COALESCE(started_at, ended_at, '') DESC, id DESC
LIMIT ?
`);
const countSessionsStatement = db.prepare(`
SELECT COUNT(*) AS total_count
FROM sessions
`);
const getSessionStatement = db.prepare(`
SELECT *
FROM sessions
WHERE id = ?
`);
const getSessionSkillRunsStatement = db.prepare(`
SELECT *
FROM skill_runs
WHERE session_id = ?
ORDER BY created_at DESC, id DESC
`);
const getSessionDecisionsStatement = db.prepare(`
SELECT *
FROM decisions
WHERE session_id = ?
ORDER BY created_at DESC, id DESC
`);
const listActiveSessionsStatement = db.prepare(`
SELECT *
FROM sessions
WHERE ended_at IS NULL
AND state IN ('active', 'running', 'idle')
ORDER BY COALESCE(started_at, ended_at, '') DESC, id DESC
LIMIT ?
`);
const countActiveSessionsStatement = db.prepare(`
SELECT COUNT(*) AS total_count
FROM sessions
WHERE ended_at IS NULL
AND state IN ('active', 'running', 'idle')
`);
const listRecentSkillRunsStatement = db.prepare(`
SELECT *
FROM skill_runs
ORDER BY created_at DESC, id DESC
LIMIT ?
`);
const listInstallStateStatement = db.prepare(`
SELECT *
FROM install_state
ORDER BY installed_at DESC, target_id ASC
`);
const countPendingGovernanceStatement = db.prepare(`
SELECT COUNT(*) AS total_count
FROM governance_events
WHERE resolved_at IS NULL
`);
const listPendingGovernanceStatement = db.prepare(`
SELECT *
FROM governance_events
WHERE resolved_at IS NULL
ORDER BY created_at DESC, id DESC
LIMIT ?
`);
const getSkillVersionStatement = db.prepare(`
SELECT *
FROM skill_versions
WHERE skill_id = ? AND version = ?
`);
const upsertSessionStatement = db.prepare(`
INSERT INTO sessions (
id,
adapter_id,
harness,
state,
repo_root,
started_at,
ended_at,
snapshot
) VALUES (
@id,
@adapter_id,
@harness,
@state,
@repo_root,
@started_at,
@ended_at,
@snapshot
)
ON CONFLICT(id) DO UPDATE SET
adapter_id = excluded.adapter_id,
harness = excluded.harness,
state = excluded.state,
repo_root = excluded.repo_root,
started_at = excluded.started_at,
ended_at = excluded.ended_at,
snapshot = excluded.snapshot
`);
const insertSkillRunStatement = db.prepare(`
INSERT INTO skill_runs (
id,
skill_id,
skill_version,
session_id,
task_description,
outcome,
failure_reason,
tokens_used,
duration_ms,
user_feedback,
created_at
) VALUES (
@id,
@skill_id,
@skill_version,
@session_id,
@task_description,
@outcome,
@failure_reason,
@tokens_used,
@duration_ms,
@user_feedback,
@created_at
)
ON CONFLICT(id) DO UPDATE SET
skill_id = excluded.skill_id,
skill_version = excluded.skill_version,
session_id = excluded.session_id,
task_description = excluded.task_description,
outcome = excluded.outcome,
failure_reason = excluded.failure_reason,
tokens_used = excluded.tokens_used,
duration_ms = excluded.duration_ms,
user_feedback = excluded.user_feedback,
created_at = excluded.created_at
`);
const upsertSkillVersionStatement = db.prepare(`
INSERT INTO skill_versions (
skill_id,
version,
content_hash,
amendment_reason,
promoted_at,
rolled_back_at
) VALUES (
@skill_id,
@version,
@content_hash,
@amendment_reason,
@promoted_at,
@rolled_back_at
)
ON CONFLICT(skill_id, version) DO UPDATE SET
content_hash = excluded.content_hash,
amendment_reason = excluded.amendment_reason,
promoted_at = excluded.promoted_at,
rolled_back_at = excluded.rolled_back_at
`);
const insertDecisionStatement = db.prepare(`
INSERT INTO decisions (
id,
session_id,
title,
rationale,
alternatives,
supersedes,
status,
created_at
) VALUES (
@id,
@session_id,
@title,
@rationale,
@alternatives,
@supersedes,
@status,
@created_at
)
ON CONFLICT(id) DO UPDATE SET
session_id = excluded.session_id,
title = excluded.title,
rationale = excluded.rationale,
alternatives = excluded.alternatives,
supersedes = excluded.supersedes,
status = excluded.status,
created_at = excluded.created_at
`);
const upsertInstallStateStatement = db.prepare(`
INSERT INTO install_state (
target_id,
target_root,
profile,
modules,
operations,
installed_at,
source_version
) VALUES (
@target_id,
@target_root,
@profile,
@modules,
@operations,
@installed_at,
@source_version
)
ON CONFLICT(target_id, target_root) DO UPDATE SET
profile = excluded.profile,
modules = excluded.modules,
operations = excluded.operations,
installed_at = excluded.installed_at,
source_version = excluded.source_version
`);
const insertGovernanceEventStatement = db.prepare(`
INSERT INTO governance_events (
id,
session_id,
event_type,
payload,
resolved_at,
resolution,
created_at
) VALUES (
@id,
@session_id,
@event_type,
@payload,
@resolved_at,
@resolution,
@created_at
)
ON CONFLICT(id) DO UPDATE SET
session_id = excluded.session_id,
event_type = excluded.event_type,
payload = excluded.payload,
resolved_at = excluded.resolved_at,
resolution = excluded.resolution,
created_at = excluded.created_at
`);
function getSessionById(id) {
const row = getSessionStatement.get(id);
return row ? mapSessionRow(row) : null;
}
function listRecentSessions(options = {}) {
const limit = normalizeLimit(options.limit, 10);
return {
totalCount: countSessionsStatement.get().total_count,
sessions: listRecentSessionsStatement.all(limit).map(mapSessionRow),
};
}
function getSessionDetail(id) {
const session = getSessionById(id);
if (!session) {
return null;
}
const workers = Array.isArray(session.snapshot && session.snapshot.workers)
? session.snapshot.workers.map(worker => ({ ...worker }))
: [];
return {
session,
workers,
skillRuns: getSessionSkillRunsStatement.all(id).map(mapSkillRunRow),
decisions: getSessionDecisionsStatement.all(id).map(mapDecisionRow),
};
}
function getStatus(options = {}) {
const activeLimit = normalizeLimit(options.activeLimit, 5);
const recentSkillRunLimit = normalizeLimit(options.recentSkillRunLimit, 20);
const pendingLimit = normalizeLimit(options.pendingLimit, 5);
const activeSessions = listActiveSessionsStatement.all(activeLimit).map(mapSessionRow);
const recentSkillRuns = listRecentSkillRunsStatement.all(recentSkillRunLimit).map(mapSkillRunRow);
const installations = listInstallStateStatement.all().map(mapInstallStateRow);
const pendingGovernanceEvents = listPendingGovernanceStatement.all(pendingLimit).map(mapGovernanceEventRow);
return {
generatedAt: new Date().toISOString(),
activeSessions: {
activeCount: countActiveSessionsStatement.get().total_count,
sessions: activeSessions,
},
skillRuns: {
windowSize: recentSkillRunLimit,
summary: summarizeSkillRuns(recentSkillRuns),
recent: recentSkillRuns,
},
installHealth: summarizeInstallHealth(installations),
governance: {
pendingCount: countPendingGovernanceStatement.get().total_count,
events: pendingGovernanceEvents,
},
};
}
return {
getSessionById,
getSessionDetail,
getStatus,
insertDecision(decision) {
const normalized = normalizeDecisionInput(decision);
assertValidEntity('decision', normalized);
insertDecisionStatement.run({
id: normalized.id,
session_id: normalized.sessionId,
title: normalized.title,
rationale: normalized.rationale,
alternatives: stringifyJson(normalized.alternatives, 'decision.alternatives'),
supersedes: normalized.supersedes,
status: normalized.status,
created_at: normalized.createdAt,
});
return normalized;
},
insertGovernanceEvent(governanceEvent) {
const normalized = normalizeGovernanceEventInput(governanceEvent);
assertValidEntity('governanceEvent', normalized);
insertGovernanceEventStatement.run({
id: normalized.id,
session_id: normalized.sessionId,
event_type: normalized.eventType,
payload: stringifyJson(normalized.payload, 'governanceEvent.payload'),
resolved_at: normalized.resolvedAt,
resolution: normalized.resolution,
created_at: normalized.createdAt,
});
return normalized;
},
insertSkillRun(skillRun) {
const normalized = normalizeSkillRunInput(skillRun);
assertValidEntity('skillRun', normalized);
insertSkillRunStatement.run({
id: normalized.id,
skill_id: normalized.skillId,
skill_version: normalized.skillVersion,
session_id: normalized.sessionId,
task_description: normalized.taskDescription,
outcome: normalized.outcome,
failure_reason: normalized.failureReason,
tokens_used: normalized.tokensUsed,
duration_ms: normalized.durationMs,
user_feedback: normalized.userFeedback,
created_at: normalized.createdAt,
});
return normalized;
},
listRecentSessions,
upsertInstallState(installState) {
const normalized = normalizeInstallStateInput(installState);
assertValidEntity('installState', normalized);
upsertInstallStateStatement.run({
target_id: normalized.targetId,
target_root: normalized.targetRoot,
profile: normalized.profile,
modules: stringifyJson(normalized.modules, 'installState.modules'),
operations: stringifyJson(normalized.operations, 'installState.operations'),
installed_at: normalized.installedAt,
source_version: normalized.sourceVersion,
});
return normalized;
},
upsertSession(session) {
const normalized = normalizeSessionInput(session);
assertValidEntity('session', normalized);
upsertSessionStatement.run({
id: normalized.id,
adapter_id: normalized.adapterId,
harness: normalized.harness,
state: normalized.state,
repo_root: normalized.repoRoot,
started_at: normalized.startedAt,
ended_at: normalized.endedAt,
snapshot: stringifyJson(normalized.snapshot, 'session.snapshot'),
});
return getSessionById(normalized.id);
},
upsertSkillVersion(skillVersion) {
const normalized = normalizeSkillVersionInput(skillVersion);
assertValidEntity('skillVersion', normalized);
upsertSkillVersionStatement.run({
skill_id: normalized.skillId,
version: normalized.version,
content_hash: normalized.contentHash,
amendment_reason: normalized.amendmentReason,
promoted_at: normalized.promotedAt,
rolled_back_at: normalized.rolledBackAt,
});
const row = getSkillVersionStatement.get(normalized.skillId, normalized.version);
return row ? mapSkillVersionRow(row) : null;
},
};
}
module.exports = {
ACTIVE_SESSION_STATES,
FAILURE_OUTCOMES,
SUCCESS_OUTCOMES,
createQueryApi,
};

View File

@@ -0,0 +1,92 @@
'use strict';
const fs = require('fs');
const path = require('path');
const Ajv = require('ajv');
const SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'state-store.schema.json');
const ENTITY_DEFINITIONS = {
session: 'session',
skillRun: 'skillRun',
skillVersion: 'skillVersion',
decision: 'decision',
installState: 'installState',
governanceEvent: 'governanceEvent',
};
let cachedSchema = null;
let cachedAjv = null;
const cachedValidators = new Map();
function readSchema() {
if (cachedSchema) {
return cachedSchema;
}
cachedSchema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'));
return cachedSchema;
}
function getAjv() {
if (cachedAjv) {
return cachedAjv;
}
cachedAjv = new Ajv({
allErrors: true,
strict: false,
});
return cachedAjv;
}
function getEntityValidator(entityName) {
if (cachedValidators.has(entityName)) {
return cachedValidators.get(entityName);
}
const schema = readSchema();
const definitionName = ENTITY_DEFINITIONS[entityName];
if (!definitionName || !schema.$defs || !schema.$defs[definitionName]) {
throw new Error(`Unknown state-store schema entity: ${entityName}`);
}
const validatorSchema = {
$schema: schema.$schema,
...schema.$defs[definitionName],
$defs: schema.$defs,
};
const validator = getAjv().compile(validatorSchema);
cachedValidators.set(entityName, validator);
return validator;
}
function formatValidationErrors(errors = []) {
return errors
.map(error => `${error.instancePath || '/'} ${error.message}`)
.join('; ');
}
function validateEntity(entityName, payload) {
const validator = getEntityValidator(entityName);
const valid = validator(payload);
return {
valid,
errors: validator.errors || [],
};
}
function assertValidEntity(entityName, payload, label) {
const result = validateEntity(entityName, payload);
if (!result.valid) {
throw new Error(`Invalid ${entityName}${label ? ` (${label})` : ''}: ${formatValidationErrors(result.errors)}`);
}
}
module.exports = {
assertValidEntity,
formatValidationErrors,
readSchema,
validateEntity,
};

View File

@@ -0,0 +1,598 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function slugify(value, fallback = 'worker') {
const normalized = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || fallback;
}
function renderTemplate(template, variables) {
if (typeof template !== 'string' || template.trim().length === 0) {
throw new Error('launcherCommand must be a non-empty string');
}
return template.replace(/\{([a-z_]+)\}/g, (match, key) => {
if (!(key in variables)) {
throw new Error(`Unknown template variable: ${key}`);
}
return String(variables[key]);
});
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function formatCommand(program, args) {
return [program, ...args.map(shellQuote)].join(' ');
}
function buildTemplateVariables(values) {
return Object.entries(values).reduce((accumulator, [key, value]) => {
const stringValue = String(value);
const quotedValue = shellQuote(stringValue);
accumulator[key] = stringValue;
accumulator[`${key}_raw`] = stringValue;
accumulator[`${key}_sh`] = quotedValue;
return accumulator;
}, {});
}
function buildSessionBannerCommand(sessionName, coordinationDir) {
return `printf '%s\\n' ${shellQuote(`Session: ${sessionName}`)} ${shellQuote(`Coordination: ${coordinationDir}`)}`;
}
function normalizeSeedPaths(seedPaths, repoRoot) {
const resolvedRepoRoot = path.resolve(repoRoot);
const entries = Array.isArray(seedPaths) ? seedPaths : [];
const seen = new Set();
const normalized = [];
for (const entry of entries) {
if (typeof entry !== 'string' || entry.trim().length === 0) {
continue;
}
const absolutePath = path.resolve(resolvedRepoRoot, entry);
const relativePath = path.relative(resolvedRepoRoot, absolutePath);
if (
relativePath.startsWith('..') ||
path.isAbsolute(relativePath)
) {
throw new Error(`seedPaths entries must stay inside repoRoot: ${entry}`);
}
const normalizedPath = relativePath.split(path.sep).join('/');
if (seen.has(normalizedPath)) {
continue;
}
seen.add(normalizedPath);
normalized.push(normalizedPath);
}
return normalized;
}
function overlaySeedPaths({ repoRoot, seedPaths, worktreePath }) {
const normalizedSeedPaths = normalizeSeedPaths(seedPaths, repoRoot);
for (const seedPath of normalizedSeedPaths) {
const sourcePath = path.join(repoRoot, seedPath);
const destinationPath = path.join(worktreePath, seedPath);
if (!fs.existsSync(sourcePath)) {
throw new Error(`Seed path does not exist in repoRoot: ${seedPath}`);
}
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.rmSync(destinationPath, { force: true, recursive: true });
fs.cpSync(sourcePath, destinationPath, {
dereference: false,
force: true,
preserveTimestamps: true,
recursive: true
});
}
}
function buildWorkerArtifacts(workerPlan) {
const seededPathsSection = workerPlan.seedPaths.length > 0
? [
'',
'## Seeded Local Overlays',
...workerPlan.seedPaths.map(seedPath => `- \`${seedPath}\``)
]
: [];
return {
dir: workerPlan.coordinationDir,
files: [
{
path: workerPlan.taskFilePath,
content: [
`# Worker Task: ${workerPlan.workerName}`,
'',
`- Session: \`${workerPlan.sessionName}\``,
`- Repo root: \`${workerPlan.repoRoot}\``,
`- Worktree: \`${workerPlan.worktreePath}\``,
`- Branch: \`${workerPlan.branchName}\``,
`- Launcher status file: \`${workerPlan.statusFilePath}\``,
`- Launcher handoff file: \`${workerPlan.handoffFilePath}\``,
...seededPathsSection,
'',
'## Objective',
workerPlan.task,
'',
'## Completion',
'Do not spawn subagents or external agents for this task.',
'Report results in your final response.',
`The worker launcher captures your response in \`${workerPlan.handoffFilePath}\` automatically.`,
`The worker launcher updates \`${workerPlan.statusFilePath}\` automatically.`
].join('\n')
},
{
path: workerPlan.handoffFilePath,
content: [
`# Handoff: ${workerPlan.workerName}`,
'',
'## Summary',
'- Pending',
'',
'## Files Changed',
'- Pending',
'',
'## Tests / Verification',
'- Pending',
'',
'## Follow-ups',
'- Pending'
].join('\n')
},
{
path: workerPlan.statusFilePath,
content: [
`# Status: ${workerPlan.workerName}`,
'',
'- State: not started',
`- Worktree: \`${workerPlan.worktreePath}\``,
`- Branch: \`${workerPlan.branchName}\``
].join('\n')
}
]
};
}
function buildOrchestrationPlan(config = {}) {
const repoRoot = path.resolve(config.repoRoot || process.cwd());
const repoName = path.basename(repoRoot);
const workers = Array.isArray(config.workers) ? config.workers : [];
const globalSeedPaths = normalizeSeedPaths(config.seedPaths, repoRoot);
const sessionName = slugify(config.sessionName || repoName, 'session');
const worktreeRoot = path.resolve(config.worktreeRoot || path.dirname(repoRoot));
const coordinationRoot = path.resolve(
config.coordinationRoot || path.join(repoRoot, '.orchestration')
);
const coordinationDir = path.join(coordinationRoot, sessionName);
const baseRef = config.baseRef || 'HEAD';
const defaultLauncher = config.launcherCommand || '';
if (workers.length === 0) {
throw new Error('buildOrchestrationPlan requires at least one worker');
}
const seenSlugs = new Set();
const workerPlans = workers.map((worker, index) => {
if (!worker || typeof worker.task !== 'string' || worker.task.trim().length === 0) {
throw new Error(`Worker ${index + 1} is missing a task`);
}
const workerName = worker.name || `worker-${index + 1}`;
const workerSlug = slugify(workerName, `worker-${index + 1}`);
if (seenSlugs.has(workerSlug)) {
throw new Error(`Workers must have unique slugs — duplicate: ${workerSlug}`);
}
seenSlugs.add(workerSlug);
const branchName = `orchestrator-${sessionName}-${workerSlug}`;
const worktreePath = path.join(worktreeRoot, `${repoName}-${sessionName}-${workerSlug}`);
const workerCoordinationDir = path.join(coordinationDir, workerSlug);
const taskFilePath = path.join(workerCoordinationDir, 'task.md');
const handoffFilePath = path.join(workerCoordinationDir, 'handoff.md');
const statusFilePath = path.join(workerCoordinationDir, 'status.md');
const launcherCommand = worker.launcherCommand || defaultLauncher;
const workerSeedPaths = normalizeSeedPaths(worker.seedPaths, repoRoot);
const seedPaths = normalizeSeedPaths([...globalSeedPaths, ...workerSeedPaths], repoRoot);
const templateVariables = buildTemplateVariables({
branch_name: branchName,
handoff_file: handoffFilePath,
repo_root: repoRoot,
session_name: sessionName,
status_file: statusFilePath,
task_file: taskFilePath,
worker_name: workerName,
worker_slug: workerSlug,
worktree_path: worktreePath
});
if (!launcherCommand) {
throw new Error(`Worker ${workerName} is missing a launcherCommand`);
}
const gitArgs = ['worktree', 'add', '-b', branchName, worktreePath, baseRef];
return {
branchName,
coordinationDir: workerCoordinationDir,
gitArgs,
gitCommand: formatCommand('git', gitArgs),
handoffFilePath,
launchCommand: renderTemplate(launcherCommand, templateVariables),
repoRoot,
sessionName,
seedPaths,
statusFilePath,
task: worker.task.trim(),
taskFilePath,
workerName,
workerSlug,
worktreePath
};
});
const tmuxCommands = [
{
cmd: 'tmux',
args: ['new-session', '-d', '-s', sessionName, '-n', 'orchestrator', '-c', repoRoot],
description: 'Create detached tmux session'
},
{
cmd: 'tmux',
args: [
'send-keys',
'-t',
sessionName,
buildSessionBannerCommand(sessionName, coordinationDir),
'C-m'
],
description: 'Print orchestrator session details'
}
];
for (const workerPlan of workerPlans) {
tmuxCommands.push(
{
cmd: 'tmux',
args: ['split-window', '-d', '-t', sessionName, '-c', workerPlan.worktreePath],
description: `Create pane for ${workerPlan.workerName}`
},
{
cmd: 'tmux',
args: ['select-layout', '-t', sessionName, 'tiled'],
description: 'Arrange panes in tiled layout'
},
{
cmd: 'tmux',
args: ['select-pane', '-t', '<pane-id>', '-T', workerPlan.workerSlug],
description: `Label pane ${workerPlan.workerSlug}`
},
{
cmd: 'tmux',
args: [
'send-keys',
'-t',
'<pane-id>',
`cd ${shellQuote(workerPlan.worktreePath)} && ${workerPlan.launchCommand}`,
'C-m'
],
description: `Launch worker ${workerPlan.workerName}`
}
);
}
return {
baseRef,
coordinationDir,
replaceExisting: Boolean(config.replaceExisting),
repoRoot,
sessionName,
tmuxCommands,
workerPlans
};
}
function materializePlan(plan) {
for (const workerPlan of plan.workerPlans) {
const artifacts = buildWorkerArtifacts(workerPlan);
fs.mkdirSync(artifacts.dir, { recursive: true });
for (const file of artifacts.files) {
fs.writeFileSync(file.path, file.content + '\n', 'utf8');
}
}
}
function runCommand(program, args, options = {}) {
const result = spawnSync(program, args, {
cwd: options.cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = (result.stderr || '').trim();
throw new Error(`${program} ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`);
}
return result;
}
function commandSucceeds(program, args, options = {}) {
const result = spawnSync(program, args, {
cwd: options.cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
return result.status === 0;
}
function canonicalizePath(targetPath) {
const resolvedPath = path.resolve(targetPath);
try {
return fs.realpathSync.native(resolvedPath);
} catch (_error) {
const parentPath = path.dirname(resolvedPath);
try {
return path.join(fs.realpathSync.native(parentPath), path.basename(resolvedPath));
} catch (_parentError) {
return resolvedPath;
}
}
}
function branchExists(repoRoot, branchName) {
return commandSucceeds('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`], {
cwd: repoRoot
});
}
function listWorktrees(repoRoot) {
const listed = runCommand('git', ['worktree', 'list', '--porcelain'], { cwd: repoRoot });
const lines = (listed.stdout || '').split('\n');
const worktrees = [];
for (const line of lines) {
if (line.startsWith('worktree ')) {
const listedPath = line.slice('worktree '.length).trim();
worktrees.push({
listedPath,
canonicalPath: canonicalizePath(listedPath)
});
}
}
return worktrees;
}
function cleanupExisting(plan) {
runCommand('git', ['worktree', 'prune', '--expire', 'now'], { cwd: plan.repoRoot });
const hasSession = spawnSync('tmux', ['has-session', '-t', plan.sessionName], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
if (hasSession.status === 0) {
runCommand('tmux', ['kill-session', '-t', plan.sessionName], { cwd: plan.repoRoot });
}
for (const workerPlan of plan.workerPlans) {
const expectedWorktreePath = canonicalizePath(workerPlan.worktreePath);
const existingWorktree = listWorktrees(plan.repoRoot).find(
worktree => worktree.canonicalPath === expectedWorktreePath
);
if (existingWorktree) {
runCommand('git', ['worktree', 'remove', '--force', existingWorktree.listedPath], {
cwd: plan.repoRoot
});
}
if (fs.existsSync(workerPlan.worktreePath)) {
fs.rmSync(workerPlan.worktreePath, { force: true, recursive: true });
}
runCommand('git', ['worktree', 'prune', '--expire', 'now'], { cwd: plan.repoRoot });
if (branchExists(plan.repoRoot, workerPlan.branchName)) {
runCommand('git', ['branch', '-D', workerPlan.branchName], { cwd: plan.repoRoot });
}
}
}
function rollbackCreatedResources(plan, createdState, runtime = {}) {
const runCommandImpl = runtime.runCommand || runCommand;
const listWorktreesImpl = runtime.listWorktrees || listWorktrees;
const branchExistsImpl = runtime.branchExists || branchExists;
const errors = [];
if (createdState.sessionCreated) {
try {
runCommandImpl('tmux', ['kill-session', '-t', plan.sessionName], { cwd: plan.repoRoot });
} catch (error) {
errors.push(error.message);
}
}
for (const workerPlan of [...createdState.workerPlans].reverse()) {
const expectedWorktreePath = canonicalizePath(workerPlan.worktreePath);
const existingWorktree = listWorktreesImpl(plan.repoRoot).find(
worktree => worktree.canonicalPath === expectedWorktreePath
);
if (existingWorktree) {
try {
runCommandImpl('git', ['worktree', 'remove', '--force', existingWorktree.listedPath], {
cwd: plan.repoRoot
});
} catch (error) {
errors.push(error.message);
}
} else if (fs.existsSync(workerPlan.worktreePath)) {
fs.rmSync(workerPlan.worktreePath, { force: true, recursive: true });
}
try {
runCommandImpl('git', ['worktree', 'prune', '--expire', 'now'], { cwd: plan.repoRoot });
} catch (error) {
errors.push(error.message);
}
if (branchExistsImpl(plan.repoRoot, workerPlan.branchName)) {
try {
runCommandImpl('git', ['branch', '-D', workerPlan.branchName], { cwd: plan.repoRoot });
} catch (error) {
errors.push(error.message);
}
}
}
if (createdState.removeCoordinationDir && fs.existsSync(plan.coordinationDir)) {
fs.rmSync(plan.coordinationDir, { force: true, recursive: true });
}
if (errors.length > 0) {
throw new Error(`rollback failed: ${errors.join('; ')}`);
}
}
function executePlan(plan, runtime = {}) {
const spawnSyncImpl = runtime.spawnSync || spawnSync;
const runCommandImpl = runtime.runCommand || runCommand;
const materializePlanImpl = runtime.materializePlan || materializePlan;
const overlaySeedPathsImpl = runtime.overlaySeedPaths || overlaySeedPaths;
const cleanupExistingImpl = runtime.cleanupExisting || cleanupExisting;
const rollbackCreatedResourcesImpl = runtime.rollbackCreatedResources || rollbackCreatedResources;
const createdState = {
workerPlans: [],
sessionCreated: false,
removeCoordinationDir: !fs.existsSync(plan.coordinationDir)
};
runCommandImpl('git', ['rev-parse', '--is-inside-work-tree'], { cwd: plan.repoRoot });
runCommandImpl('tmux', ['-V']);
if (plan.replaceExisting) {
cleanupExistingImpl(plan);
} else {
const hasSession = spawnSyncImpl('tmux', ['has-session', '-t', plan.sessionName], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
if (hasSession.status === 0) {
throw new Error(`tmux session already exists: ${plan.sessionName}`);
}
}
try {
materializePlanImpl(plan);
for (const workerPlan of plan.workerPlans) {
runCommandImpl('git', workerPlan.gitArgs, { cwd: plan.repoRoot });
createdState.workerPlans.push(workerPlan);
overlaySeedPathsImpl({
repoRoot: plan.repoRoot,
seedPaths: workerPlan.seedPaths,
worktreePath: workerPlan.worktreePath
});
}
runCommandImpl(
'tmux',
['new-session', '-d', '-s', plan.sessionName, '-n', 'orchestrator', '-c', plan.repoRoot],
{ cwd: plan.repoRoot }
);
createdState.sessionCreated = true;
runCommandImpl(
'tmux',
[
'send-keys',
'-t',
plan.sessionName,
buildSessionBannerCommand(plan.sessionName, plan.coordinationDir),
'C-m'
],
{ cwd: plan.repoRoot }
);
for (const workerPlan of plan.workerPlans) {
const splitResult = runCommandImpl(
'tmux',
['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', plan.sessionName, '-c', workerPlan.worktreePath],
{ cwd: plan.repoRoot }
);
const paneId = splitResult.stdout.trim();
if (!paneId) {
throw new Error(`tmux split-window did not return a pane id for ${workerPlan.workerName}`);
}
runCommandImpl('tmux', ['select-layout', '-t', plan.sessionName, 'tiled'], { cwd: plan.repoRoot });
runCommandImpl('tmux', ['select-pane', '-t', paneId, '-T', workerPlan.workerSlug], {
cwd: plan.repoRoot
});
runCommandImpl(
'tmux',
[
'send-keys',
'-t',
paneId,
`cd ${shellQuote(workerPlan.worktreePath)} && ${workerPlan.launchCommand}`,
'C-m'
],
{ cwd: plan.repoRoot }
);
}
} catch (error) {
try {
rollbackCreatedResourcesImpl(plan, createdState, {
branchExists: runtime.branchExists,
listWorktrees: runtime.listWorktrees,
runCommand: runCommandImpl
});
} catch (cleanupError) {
error.message = `${error.message}; cleanup failed: ${cleanupError.message}`;
}
throw error;
}
return {
coordinationDir: plan.coordinationDir,
sessionName: plan.sessionName,
workerCount: plan.workerPlans.length
};
}
module.exports = {
buildOrchestrationPlan,
executePlan,
materializePlan,
normalizeSeedPaths,
overlaySeedPaths,
rollbackCreatedResources,
renderTemplate,
slugify
};

View File

@@ -0,0 +1,196 @@
/**
* Cross-platform utility functions for Claude Code hooks and scripts.
* Works on Windows, macOS, and Linux.
*/
import type { ExecSyncOptions } from 'child_process';
// Platform detection
export const isWindows: boolean;
export const isMacOS: boolean;
export const isLinux: boolean;
// --- Directories ---
/** Get the user's home directory (cross-platform) */
export function getHomeDir(): string;
/** Get the Claude config directory (~/.claude) */
export function getClaudeDir(): string;
/** Get the canonical ECC sessions directory (~/.claude/session-data) */
export function getSessionsDir(): string;
/** Get the legacy Claude-managed sessions directory (~/.claude/sessions) */
export function getLegacySessionsDir(): string;
/** Get session directories to search, with canonical storage first and legacy fallback second */
export function getSessionSearchDirs(): string[];
/** Get the learned skills directory (~/.claude/skills/learned) */
export function getLearnedSkillsDir(): string;
/** Get the temp directory (cross-platform) */
export function getTempDir(): string;
/**
* Ensure a directory exists, creating it recursively if needed.
* Handles EEXIST race conditions from concurrent creation.
* @throws If directory cannot be created (e.g., permission denied)
*/
export function ensureDir(dirPath: string): string;
// --- Date/Time ---
/** Get current date in YYYY-MM-DD format */
export function getDateString(): string;
/** Get current time in HH:MM format */
export function getTimeString(): string;
/** Get current datetime in YYYY-MM-DD HH:MM:SS format */
export function getDateTimeString(): string;
// --- Session/Project ---
/**
* Sanitize a string for use as a session filename segment.
* Replaces invalid characters, strips leading dots, and returns null when
* nothing meaningful remains. Non-ASCII names are hashed for stability.
*/
export function sanitizeSessionId(raw: string | null | undefined): string | null;
/**
* Get short session ID from CLAUDE_SESSION_ID environment variable.
* Returns last 8 characters, falls back to a sanitized project name then the provided fallback.
*/
export function getSessionIdShort(fallback?: string): string;
/** Get the git repository name from the current working directory */
export function getGitRepoName(): string | null;
/** Get project name from git repo or current directory basename */
export function getProjectName(): string | null;
// --- File operations ---
export interface FileMatch {
/** Absolute path to the matching file */
path: string;
/** Modification time in milliseconds since epoch */
mtime: number;
}
export interface FindFilesOptions {
/** Maximum age in days. Only files modified within this many days are returned. */
maxAge?: number | null;
/** Whether to search subdirectories recursively */
recursive?: boolean;
}
/**
* Find files matching a glob-like pattern in a directory.
* Supports `*` (any chars), `?` (single char), and `.` (literal dot).
* Results are sorted by modification time (newest first).
*/
export function findFiles(dir: string, pattern: string, options?: FindFilesOptions): FileMatch[];
/**
* Read a text file safely. Returns null if the file doesn't exist or can't be read.
*/
export function readFile(filePath: string): string | null;
/** Write a text file, creating parent directories if needed */
export function writeFile(filePath: string, content: string): void;
/** Append to a text file, creating parent directories if needed */
export function appendFile(filePath: string, content: string): void;
export interface ReplaceInFileOptions {
/**
* When true and search is a string, replaces ALL occurrences (uses String.replaceAll).
* Ignored for RegExp patterns — use the `g` flag instead.
*/
all?: boolean;
}
/**
* Replace text in a file (cross-platform sed alternative).
* @returns true if the file was found and updated, false if file not found
*/
export function replaceInFile(filePath: string, search: string | RegExp, replace: string, options?: ReplaceInFileOptions): boolean;
/**
* Count occurrences of a pattern in a file.
* The global flag is enforced automatically for correct counting.
*/
export function countInFile(filePath: string, pattern: string | RegExp): number;
export interface GrepMatch {
/** 1-based line number */
lineNumber: number;
/** Full content of the matching line */
content: string;
}
/** Search for a pattern in a file and return matching lines with line numbers */
export function grepFile(filePath: string, pattern: string | RegExp): GrepMatch[];
// --- Hook I/O ---
export interface ReadStdinJsonOptions {
/**
* Timeout in milliseconds. Prevents hooks from hanging indefinitely
* if stdin never closes. Default: 5000
*/
timeoutMs?: number;
/**
* Maximum stdin data size in bytes. Prevents unbounded memory growth.
* Default: 1048576 (1MB)
*/
maxSize?: number;
}
/**
* Read JSON from stdin (for hook input).
* Returns an empty object if stdin is empty, times out, or contains invalid JSON.
* Never rejects — safe to use without try-catch in hooks.
*/
export function readStdinJson(options?: ReadStdinJsonOptions): Promise<Record<string, unknown>>;
/** Log a message to stderr (visible to user in Claude Code terminal) */
export function log(message: string): void;
/** Output data to stdout (returned to Claude's context) */
export function output(data: string | Record<string, unknown>): void;
// --- System ---
/**
* Check if a command exists in PATH.
* Only allows alphanumeric, dash, underscore, and dot characters.
* WARNING: Spawns a child process (where.exe on Windows, which on Unix).
*/
export function commandExists(cmd: string): boolean;
export interface CommandResult {
success: boolean;
/** Trimmed stdout on success, stderr or error message on failure */
output: string;
}
/**
* Run a shell command and return the output.
* SECURITY: Only use with trusted, hardcoded commands.
* Never pass user-controlled input directly.
*/
export function runCommand(cmd: string, options?: ExecSyncOptions): CommandResult;
/** Check if the current directory is inside a git repository */
export function isGitRepo(): boolean;
/**
* Get git modified files (staged + unstaged), optionally filtered by regex patterns.
* Invalid regex patterns are silently skipped.
*/
export function getGitModifiedFiles(patterns?: string[]): string[];

View File

@@ -0,0 +1,625 @@
/**
* Cross-platform utility functions for Claude Code hooks and scripts
* Works on Windows, macOS, and Linux
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execSync, spawnSync } = require('child_process');
// Platform detection
const isWindows = process.platform === 'win32';
const isMacOS = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
const SESSION_DATA_DIR_NAME = 'session-data';
const LEGACY_SESSIONS_DIR_NAME = 'sessions';
const WINDOWS_RESERVED_SESSION_IDS = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
]);
/**
* Get the user's home directory (cross-platform)
*/
function getHomeDir() {
return os.homedir();
}
/**
* Get the Claude config directory
*/
function getClaudeDir() {
return path.join(getHomeDir(), '.claude');
}
/**
* Get the sessions directory
*/
function getSessionsDir() {
return path.join(getClaudeDir(), SESSION_DATA_DIR_NAME);
}
/**
* Get the legacy sessions directory used by older ECC installs
*/
function getLegacySessionsDir() {
return path.join(getClaudeDir(), LEGACY_SESSIONS_DIR_NAME);
}
/**
* Get all session directories to search, in canonical-first order
*/
function getSessionSearchDirs() {
return Array.from(new Set([getSessionsDir(), getLegacySessionsDir()]));
}
/**
* Get the learned skills directory
*/
function getLearnedSkillsDir() {
return path.join(getClaudeDir(), 'skills', 'learned');
}
/**
* Get the temp directory (cross-platform)
*/
function getTempDir() {
return os.tmpdir();
}
/**
* Ensure a directory exists (create if not)
* @param {string} dirPath - Directory path to create
* @returns {string} The directory path
* @throws {Error} If directory cannot be created (e.g., permission denied)
*/
function ensureDir(dirPath) {
try {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
} catch (err) {
// EEXIST is fine (race condition with another process creating it)
if (err.code !== 'EEXIST') {
throw new Error(`Failed to create directory '${dirPath}': ${err.message}`);
}
}
return dirPath;
}
/**
* Get current date in YYYY-MM-DD format
*/
function getDateString() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Get current time in HH:MM format
*/
function getTimeString() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* Get the git repository name
*/
function getGitRepoName() {
const result = runCommand('git rev-parse --show-toplevel');
if (!result.success) return null;
return path.basename(result.output);
}
/**
* Get project name from git repo or current directory
*/
function getProjectName() {
const repoName = getGitRepoName();
if (repoName) return repoName;
return path.basename(process.cwd()) || null;
}
/**
* Sanitize a string for use as a session filename segment.
* Replaces invalid characters with hyphens, collapses runs, strips
* leading/trailing hyphens, and removes leading dots so hidden-dir names
* like ".claude" map cleanly to "claude".
*
* Pure non-ASCII inputs get a stable 8-char hash so distinct names do not
* collapse to the same fallback session id. Mixed-script inputs retain their
* ASCII part and gain a short hash suffix for disambiguation.
*/
function sanitizeSessionId(raw) {
if (!raw || typeof raw !== 'string') return null;
const hasNonAscii = Array.from(raw).some(char => char.codePointAt(0) > 0x7f);
const normalized = raw.replace(/^\.+/, '');
const sanitized = normalized
.replace(/[^a-zA-Z0-9_-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
if (sanitized.length > 0) {
const suffix = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 6);
if (WINDOWS_RESERVED_SESSION_IDS.has(sanitized.toUpperCase())) {
return `${sanitized}-${suffix}`;
}
if (!hasNonAscii) return sanitized;
return `${sanitized}-${suffix}`;
}
const meaningful = normalized.replace(/[\s\p{P}]/gu, '');
if (meaningful.length === 0) return null;
return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
}
/**
* Get short session ID from CLAUDE_SESSION_ID environment variable
* Returns last 8 characters, falls back to a sanitized project name then 'default'.
*/
function getSessionIdShort(fallback = 'default') {
const sessionId = process.env.CLAUDE_SESSION_ID;
if (sessionId && sessionId.length > 0) {
const sanitized = sanitizeSessionId(sessionId.slice(-8));
if (sanitized) return sanitized;
}
return sanitizeSessionId(getProjectName()) || sanitizeSessionId(fallback) || 'default';
}
/**
* Get current datetime in YYYY-MM-DD HH:MM:SS format
*/
function getDateTimeString() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
/**
* Find files matching a pattern in a directory (cross-platform alternative to find)
* @param {string} dir - Directory to search
* @param {string} pattern - File pattern (e.g., "*.tmp", "*.md")
* @param {object} options - Options { maxAge: days, recursive: boolean }
*/
function findFiles(dir, pattern, options = {}) {
if (!dir || typeof dir !== 'string') return [];
if (!pattern || typeof pattern !== 'string') return [];
const { maxAge = null, recursive = false } = options;
const results = [];
if (!fs.existsSync(dir)) {
return results;
}
// Escape all regex special characters, then convert glob wildcards.
// Order matters: escape specials first, then convert * and ? to regex equivalents.
const regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
const regex = new RegExp(`^${regexPattern}$`);
function searchDir(currentDir) {
try {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isFile() && regex.test(entry.name)) {
let stats;
try {
stats = fs.statSync(fullPath);
} catch {
continue; // File deleted between readdir and stat
}
if (maxAge !== null) {
const ageInDays = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60 * 24);
if (ageInDays <= maxAge) {
results.push({ path: fullPath, mtime: stats.mtimeMs });
}
} else {
results.push({ path: fullPath, mtime: stats.mtimeMs });
}
} else if (entry.isDirectory() && recursive) {
searchDir(fullPath);
}
}
} catch (_err) {
// Ignore permission errors
}
}
searchDir(dir);
// Sort by modification time (newest first)
results.sort((a, b) => b.mtime - a.mtime);
return results;
}
/**
* Read JSON from stdin (for hook input)
* @param {object} options - Options
* @param {number} options.timeoutMs - Timeout in milliseconds (default: 5000).
* Prevents hooks from hanging indefinitely if stdin never closes.
* @returns {Promise<object>} Parsed JSON object, or empty object if stdin is empty
*/
async function readStdinJson(options = {}) {
const { timeoutMs = 5000, maxSize = 1024 * 1024 } = options;
return new Promise((resolve) => {
let data = '';
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
// Clean up stdin listeners so the event loop can exit
process.stdin.removeAllListeners('data');
process.stdin.removeAllListeners('end');
process.stdin.removeAllListeners('error');
if (process.stdin.unref) process.stdin.unref();
// Resolve with whatever we have so far rather than hanging
try {
resolve(data.trim() ? JSON.parse(data) : {});
} catch {
resolve({});
}
}
}, timeoutMs);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < maxSize) {
data += chunk;
}
});
process.stdin.on('end', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
resolve(data.trim() ? JSON.parse(data) : {});
} catch {
// Consistent with timeout path: resolve with empty object
// so hooks don't crash on malformed input
resolve({});
}
});
process.stdin.on('error', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
// Resolve with empty object so hooks don't crash on stdin errors
resolve({});
});
});
}
/**
* Log to stderr (visible to user in Claude Code)
*/
function log(message) {
console.error(message);
}
/**
* Output to stdout (returned to Claude)
*/
function output(data) {
if (typeof data === 'object') {
console.log(JSON.stringify(data));
} else {
console.log(data);
}
}
/**
* Read a text file safely
*/
function readFile(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch {
return null;
}
}
/**
* Write a text file
*/
function writeFile(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, 'utf8');
}
/**
* Append to a text file
*/
function appendFile(filePath, content) {
ensureDir(path.dirname(filePath));
fs.appendFileSync(filePath, content, 'utf8');
}
/**
* Check if a command exists in PATH
* Uses execFileSync to prevent command injection
*/
function commandExists(cmd) {
// Validate command name - only allow alphanumeric, dash, underscore, dot
if (!/^[a-zA-Z0-9_.-]+$/.test(cmd)) {
return false;
}
try {
if (isWindows) {
// Use spawnSync to avoid shell interpolation
const result = spawnSync('where', [cmd], { stdio: 'pipe' });
return result.status === 0;
} else {
const result = spawnSync('which', [cmd], { stdio: 'pipe' });
return result.status === 0;
}
} catch {
return false;
}
}
/**
* Run a command and return output
*
* SECURITY NOTE: This function executes shell commands. Only use with
* trusted, hardcoded commands. Never pass user-controlled input directly.
* For user input, use spawnSync with argument arrays instead.
*
* @param {string} cmd - Command to execute (should be trusted/hardcoded)
* @param {object} options - execSync options
*/
function runCommand(cmd, options = {}) {
// Allowlist: only permit known-safe command prefixes
const allowedPrefixes = ['git ', 'node ', 'npx ', 'which ', 'where '];
if (!allowedPrefixes.some(prefix => cmd.startsWith(prefix))) {
return { success: false, output: 'runCommand blocked: unrecognized command prefix' };
}
// Reject shell metacharacters. $() and backticks are evaluated inside
// double quotes, so block $ and ` anywhere in cmd. Other operators
// (;|&) are literal inside quotes, so only check unquoted portions.
const unquoted = cmd.replace(/"[^"]*"/g, '').replace(/'[^']*'/g, '');
if (/[;|&\n]/.test(unquoted) || /[`$]/.test(cmd)) {
return { success: false, output: 'runCommand blocked: shell metacharacters not allowed' };
}
try {
const result = execSync(cmd, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
...options
});
return { success: true, output: result.trim() };
} catch (err) {
return { success: false, output: err.stderr || err.message };
}
}
/**
* Check if current directory is a git repository
*/
function isGitRepo() {
return runCommand('git rev-parse --git-dir').success;
}
/**
* Get git modified files, optionally filtered by regex patterns
* @param {string[]} patterns - Array of regex pattern strings to filter files.
* Invalid patterns are silently skipped.
* @returns {string[]} Array of modified file paths
*/
function getGitModifiedFiles(patterns = []) {
if (!isGitRepo()) return [];
const result = runCommand('git diff --name-only HEAD');
if (!result.success) return [];
let files = result.output.split('\n').filter(Boolean);
if (patterns.length > 0) {
// Pre-compile patterns, skipping invalid ones
const compiled = [];
for (const pattern of patterns) {
if (typeof pattern !== 'string' || pattern.length === 0) continue;
try {
compiled.push(new RegExp(pattern));
} catch {
// Skip invalid regex patterns
}
}
if (compiled.length > 0) {
files = files.filter(file => compiled.some(regex => regex.test(file)));
}
}
return files;
}
/**
* Replace text in a file (cross-platform sed alternative)
* @param {string} filePath - Path to the file
* @param {string|RegExp} search - Pattern to search for. String patterns replace
* the FIRST occurrence only; use a RegExp with the `g` flag for global replacement.
* @param {string} replace - Replacement string
* @param {object} options - Options
* @param {boolean} options.all - When true and search is a string, replaces ALL
* occurrences (uses String.replaceAll). Ignored for RegExp patterns.
* @returns {boolean} true if file was written, false on error
*/
function replaceInFile(filePath, search, replace, options = {}) {
const content = readFile(filePath);
if (content === null) return false;
try {
let newContent;
if (options.all && typeof search === 'string') {
newContent = content.replaceAll(search, replace);
} else {
newContent = content.replace(search, replace);
}
writeFile(filePath, newContent);
return true;
} catch (err) {
log(`[Utils] replaceInFile failed for ${filePath}: ${err.message}`);
return false;
}
}
/**
* Count occurrences of a pattern in a file
* @param {string} filePath - Path to the file
* @param {string|RegExp} pattern - Pattern to count. Strings are treated as
* global regex patterns. RegExp instances are used as-is but the global
* flag is enforced to ensure correct counting.
* @returns {number} Number of matches found
*/
function countInFile(filePath, pattern) {
const content = readFile(filePath);
if (content === null) return 0;
let regex;
try {
if (pattern instanceof RegExp) {
// Always create new RegExp to avoid shared lastIndex state; ensure global flag
regex = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
} else if (typeof pattern === 'string') {
regex = new RegExp(pattern, 'g');
} else {
return 0;
}
} catch {
return 0; // Invalid regex pattern
}
const matches = content.match(regex);
return matches ? matches.length : 0;
}
/**
* Strip all ANSI escape sequences from a string.
*
* Handles:
* - CSI sequences: \x1b[ … <letter> (colors, cursor movement, erase, etc.)
* - OSC sequences: \x1b] … BEL/ST (window titles, hyperlinks)
* - Charset selection: \x1b(B
* - Bare ESC + single letter: \x1b <letter> (e.g. \x1bM for reverse index)
*
* @param {string} str - Input string possibly containing ANSI codes
* @returns {string} Cleaned string with all escape sequences removed
*/
function stripAnsi(str) {
if (typeof str !== 'string') return '';
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|\([A-Z]|[A-Z])/g, '');
}
/**
* Search for pattern in file and return matching lines with line numbers
*/
function grepFile(filePath, pattern) {
const content = readFile(filePath);
if (content === null) return [];
let regex;
try {
if (pattern instanceof RegExp) {
// Always create a new RegExp without the 'g' flag to prevent lastIndex
// state issues when using .test() in a loop (g flag makes .test() stateful,
// causing alternating match/miss on consecutive matching lines)
const flags = pattern.flags.replace('g', '');
regex = new RegExp(pattern.source, flags);
} else {
regex = new RegExp(pattern);
}
} catch {
return []; // Invalid regex pattern
}
const lines = content.split('\n');
const results = [];
lines.forEach((line, index) => {
if (regex.test(line)) {
results.push({ lineNumber: index + 1, content: line });
}
});
return results;
}
module.exports = {
// Platform info
isWindows,
isMacOS,
isLinux,
// Directories
getHomeDir,
getClaudeDir,
getSessionsDir,
getLegacySessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
getTempDir,
ensureDir,
// Date/Time
getDateString,
getTimeString,
getDateTimeString,
// Session/Project
sanitizeSessionId,
getSessionIdShort,
getGitRepoName,
getProjectName,
// File operations
findFiles,
readFile,
writeFile,
appendFile,
replaceInFile,
countInFile,
grepFile,
// String sanitisation
stripAnsi,
// Hook I/O
readStdinJson,
log,
output,
// System
commandExists,
runCommand,
isGitRepo,
getGitModifiedFiles
};

Some files were not shown because too many files have changed in this diff Show More