feat: deliver v1.8.0 harness reliability and parity updates

This commit is contained in:
Affaan Mustafa
2026-03-04 14:48:06 -08:00
parent 32e9c293f0
commit 48b883d741
84 changed files with 2990 additions and 725 deletions

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

@@ -8,14 +8,47 @@ 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 = fs.readdirSync(RULES_DIR, { recursive: true })
.filter(f => f.endsWith('.md'));
const files = collectRuleFiles(RULES_DIR);
let hasErrors = false;
let validatedCount = 0;

View File

@@ -1,14 +1,8 @@
#!/usr/bin/env node
/**
* NanoClaw — Barebones Agent REPL for Everything Claude Code
* NanoClaw v2 — Barebones Agent REPL for Everything Claude Code
*
* A persistent, session-aware AI agent loop that delegates to `claude -p`.
* Zero external dependencies. Markdown-as-database. Synchronous REPL.
*
* Usage:
* node scripts/claw.js
* CLAW_SESSION=my-project node scripts/claw.js
* CLAW_SKILLS=tdd-workflow,security-review node scripts/claw.js
* Zero external dependencies. Session-aware REPL around `claude -p`.
*/
'use strict';
@@ -19,29 +13,25 @@ const os = require('os');
const { spawnSync } = require('child_process');
const readline = require('readline');
// ─── Session name validation ────────────────────────────────────────────────
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);
}
// ─── Storage Adapter (Markdown-as-Database) ─────────────────────────────────
function getClawDir() {
return path.join(os.homedir(), '.claude', 'claw');
}
function getSessionPath(name) {
return path.join(getClawDir(), name + '.md');
return path.join(getClawDir(), `${name}.md`);
}
function listSessions(dir) {
const clawDir = dir || getClawDir();
if (!fs.existsSync(clawDir)) {
return [];
}
if (!fs.existsSync(clawDir)) return [];
return fs.readdirSync(clawDir)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''));
@@ -50,7 +40,7 @@ function listSessions(dir) {
function loadHistory(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (_err) {
} catch {
return '';
}
}
@@ -58,29 +48,27 @@ function loadHistory(filePath) {
function appendTurn(filePath, role, content, timestamp) {
const ts = timestamp || new Date().toISOString();
const entry = `### [${ts}] ${role}\n${content}\n---\n`;
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.appendFileSync(filePath, entry, 'utf8');
}
// ─── Context & Delegation Pipeline ──────────────────────────────────────────
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 raw = skillList !== undefined ? skillList : (process.env.CLAW_SKILLS || '');
if (!raw.trim()) {
return '';
}
const requested = normalizeSkillList(skillList !== undefined ? skillList : process.env.CLAW_SKILLS || '');
if (requested.length === 0) return '';
const names = raw.split(',').map(s => s.trim()).filter(Boolean);
const chunks = [];
for (const name of names) {
for (const name of requested) {
const skillPath = path.join(process.cwd(), 'skills', name, 'SKILL.md');
try {
const content = fs.readFileSync(skillPath, 'utf8');
chunks.push(content);
} catch (_err) {
// Gracefully skip missing skills
chunks.push(fs.readFileSync(skillPath, 'utf8'));
} catch {
// Skip missing skills silently to keep REPL usable.
}
}
@@ -89,38 +77,158 @@ function loadECCContext(skillList) {
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);
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) {
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', ['-p', fullPrompt], {
const result = spawnSync('claude', args, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, CLAUDECODE: '' },
timeout: 300000 // 5 minute timeout
timeout: 300000,
});
if (result.error) {
return '[Error: ' + result.error.message + ']';
return `[Error: ${result.error.message}]`;
}
if (result.status !== 0 && result.stderr) {
return '[Error: claude exited with code ' + result.status + ': ' + result.stderr.trim() + ']';
return `[Error: claude exited with code ${result.status}: ${result.stderr.trim()}]`;
}
return (result.stdout || '').trim();
}
// ─── REPL Commands ──────────────────────────────────────────────────────────
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 sessions = listSessions(dir);
const results = [];
for (const name of sessions) {
const p = getSessionPath(name);
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) {
if (!isValidSessionName(newSessionName)) {
return { ok: false, message: `Invalid branch session name: ${newSessionName}` };
}
const target = getSessionPath(newSessionName);
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 });
@@ -132,72 +240,73 @@ function handleHistory(sessionPath) {
const history = loadHistory(sessionPath);
if (!history) {
console.log('(no history)');
} else {
console.log(history);
return;
}
console.log(history);
}
function handleSessions(dir) {
const sessions = listSessions(dir);
if (sessions.length === 0) {
console.log('(no sessions)');
} else {
console.log('Sessions:');
for (const s of sessions) {
console.log(' - ' + s);
}
return;
}
console.log('Sessions:');
for (const s of sessions) {
console.log(` - ${s}`);
}
}
function handleHelp() {
console.log('NanoClaw REPL Commands:');
console.log(' /clear Clear current session history');
console.log(' /history Print full conversation history');
console.log(' /sessions List all saved sessions');
console.log(' /help Show this help message');
console.log(' exit Quit the REPL');
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');
}
// ─── Main REPL ──────────────────────────────────────────────────────────────
function main() {
const sessionName = process.env.CLAW_SESSION || 'default';
if (!isValidSessionName(sessionName)) {
console.error('Error: Invalid session name "' + sessionName + '". Use alphanumeric characters and hyphens only.');
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);
}
const clawDir = getClawDir();
fs.mkdirSync(clawDir, { recursive: true });
fs.mkdirSync(getClawDir(), { recursive: true });
const sessionPath = getSessionPath(sessionName);
const eccContext = loadECCContext();
const state = {
sessionName: initialSessionName,
sessionPath: getSessionPath(initialSessionName),
model: DEFAULT_MODEL,
skills: normalizeSkillList(process.env.CLAW_SKILLS || ''),
};
const requestedSkills = (process.env.CLAW_SKILLS || '').split(',').map(s => s.trim()).filter(Boolean);
const loadedCount = requestedSkills.filter(name =>
fs.existsSync(path.join(process.cwd(), 'skills', name, 'SKILL.md'))
).length;
let eccContext = loadECCContext(state.skills);
console.log('NanoClaw v1.0 — Session: ' + sessionName);
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(`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 rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const prompt = () => {
rl.question('claw> ', (input) => {
const line = input.trim();
if (!line) {
prompt();
return;
}
if (!line) return prompt();
if (line === 'exit') {
console.log('Goodbye.');
@@ -205,37 +314,123 @@ function main() {
return;
}
if (line === '/help') {
handleHelp();
return prompt();
}
if (line === '/clear') {
handleClear(sessionPath);
prompt();
return;
handleClear(state.sessionPath);
return prompt();
}
if (line === '/history') {
handleHistory(sessionPath);
prompt();
return;
handleHistory(state.sessionPath);
return prompt();
}
if (line === '/sessions') {
handleSessions();
prompt();
return;
return prompt();
}
if (line === '/help') {
handleHelp();
prompt();
return;
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();
}
// Regular message — send to Claude
const history = loadHistory(sessionPath);
appendTurn(sessionPath, 'User', line);
const response = askClaude(eccContext, history, line);
console.log('\n' + response + '\n');
appendTurn(sessionPath, 'Assistant', response);
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();
});
};
@@ -243,8 +438,6 @@ function main() {
prompt();
}
// ─── Exports & CLI Entry ────────────────────────────────────────────────────
module.exports = {
getClawDir,
getSessionPath,
@@ -252,14 +445,21 @@ module.exports = {
loadHistory,
appendTurn,
loadECCContext,
askClaude,
buildPrompt,
askClaude,
isValidSessionName,
handleClear,
handleHistory,
handleSessions,
handleHelp,
main
parseTurns,
estimateTokenCount,
getSessionMetrics,
searchSessions,
compactSession,
exportSession,
branchSession,
main,
};
if (require.main === module) {

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

78
scripts/hooks/cost-tracker.js Executable file
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

@@ -1,28 +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 = '';
process.stdin.on('data', c => (data += c));
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 = input.tool_input?.file_path || '';
const filePath = String(input.tool_input?.file_path || '');
if (
/\.(md|txt)$/.test(filePath) &&
!/(README|CLAUDE|AGENTS|CONTRIBUTING|CHANGELOG|LICENSE|SKILL)\.md$/i.test(filePath) &&
!/\.claude[/\\]plans[/\\]/.test(filePath) &&
!/(^|[/\\])(docs|skills|\.history)[/\\]/.test(filePath)
) {
if (filePath && !isAllowedDocPath(filePath)) {
console.error('[Hook] WARNING: Non-standard documentation file detected');
console.error('[Hook] File: ' + filePath);
console.error(`[Hook] File: ${filePath}`);
console.error('[Hook] Consider consolidating into README.md or docs/ directory');
}
} catch {
/* ignore parse errors */
// ignore parse errors
}
console.log(data);
process.stdout.write(data);
});

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,73 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
function splitShellSegments(command) {
const segments = [];
let current = '';
let quote = null;
for (let i = 0; i < command.length; i++) {
const ch = command[i];
if (quote) {
if (ch === quote) quote = null;
current += ch;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
current += ch;
continue;
}
const next = command[i + 1] || '';
if (ch === ';' || (ch === '&' && next === '&') || (ch === '|' && next === '|') || (ch === '&' && next !== '&')) {
if (current.trim()) segments.push(current.trim());
current = '';
if ((ch === '&' && next === '&') || (ch === '|' && next === '|')) i++;
continue;
}
current += ch;
}
if (current.trim()) segments.push(current.trim());
return segments;
}
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 => 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

@@ -1,61 +1,9 @@
#!/usr/bin/env node
/**
* PreToolUse Hook: Warn about non-standard documentation files
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs before Write tool use. If the file is a .md or .txt file that isn't
* a standard documentation file (README, CLAUDE, AGENTS, etc.) or in an
* expected directory (docs/, skills/, .claude/plans/), warns the user.
*
* Exit code 0 — warn only, does not block.
* Backward-compatible doc warning hook entrypoint.
* Kept for consumers that still reference pre-write-doc-warn.js directly.
*/
const path = require('path');
'use strict';
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.length > remaining ? chunk.slice(0, remaining) : chunk;
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path || '';
// Only check .md and .txt files
if (!/\.(md|txt)$/.test(filePath)) {
process.stdout.write(data);
return;
}
// Allow standard documentation files
const basename = path.basename(filePath);
if (/^(README|CLAUDE|AGENTS|CONTRIBUTING|CHANGELOG|LICENSE|SKILL)\.md$/i.test(basename)) {
process.stdout.write(data);
return;
}
// Allow files in .claude/plans/, docs/, and skills/ directories
const normalized = filePath.replace(/\\/g, '/');
if (/\.claude\/plans\//.test(normalized) || /(^|\/)(docs|skills)\//.test(normalized)) {
process.stdout.write(data);
return;
}
// Warn about non-standard documentation files
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 {
// Parse error — pass through
}
process.stdout.write(data);
});
require('./doc-file-warning.js');

98
scripts/hooks/quality-gate.js Executable file
View File

@@ -0,0 +1,98 @@
#!/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
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const MAX_STDIN = 1024 * 1024;
let raw = '';
function run(command, args, cwd = process.cwd()) {
return spawnSync(command, args, {
cwd,
encoding: 'utf8',
env: process.env,
});
}
function log(msg) {
process.stderr.write(`${msg}\n`);
}
function maybeRunQualityGate(filePath) {
if (!filePath || !fs.existsSync(filePath)) {
return;
}
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)) {
// Prefer biome if present
if (fs.existsSync(path.join(process.cwd(), 'biome.json')) || fs.existsSync(path.join(process.cwd(), 'biome.jsonc'))) {
const args = ['biome', 'check', filePath];
if (fix) args.push('--write');
const result = run('npx', args);
if (result.status !== 0 && strict) {
log(`[QualityGate] Biome check failed for ${filePath}`);
}
return;
}
// Fallback to prettier when installed
const prettierArgs = ['prettier', '--check', filePath];
if (fix) {
prettierArgs[1] = '--write';
}
const prettier = run('npx', prettierArgs);
if (prettier.status !== 0 && strict) {
log(`[QualityGate] Prettier check failed for ${filePath}`);
}
return;
}
if (ext === '.go' && fix) {
run('gofmt', ['-w', filePath]);
return;
}
if (ext === '.py') {
const args = ['format'];
if (!fix) args.push('--check');
args.push(filePath);
const r = run('ruff', args);
if (r.status !== 0 && strict) {
log(`[QualityGate] Ruff check failed for ${filePath}`);
}
}
}
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 filePath = String(input.tool_input?.file_path || '');
maybeRunQualityGate(filePath);
} catch {
// Ignore parse errors.
}
process.stdout.write(raw);
});

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
HOOK_ID="${1:-}"
REL_SCRIPT_PATH="${2:-}"
PROFILES_CSV="${3:-standard,strict}"
# 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 "${CLAUDE_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="${CLAUDE_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"

80
scripts/hooks/run-with-flags.js Executable file
View File

@@ -0,0 +1,80 @@
#!/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 = '';
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', () => resolve(raw));
process.stdin.on('error', () => resolve(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 = 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 scriptPath = path.join(pluginRoot, relScriptPath);
if (!fs.existsSync(scriptPath)) {
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
process.stdout.write(raw);
process.exit(0);
}
const result = spawnSync('node', [scriptPath], {
input: raw,
encoding: 'utf8',
env: process.env,
cwd: process.cwd(),
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
const code = Number.isInteger(result.status) ? result.status : 0;
process.exit(code);
}
main().catch(err => {
process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`);
process.exit(0);
});

View File

@@ -0,0 +1,15 @@
#!/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', () => {
process.stdout.write(raw);
});

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env node
/**
* Stop Hook (Session End) - Persist learnings when session ends
* Stop Hook (Session End) - Persist learnings during active sessions
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs when Claude session ends. Extracts a meaningful summary from
* the session transcript (via stdin JSON transcript_path) and saves it
* to a session file for cross-session continuity.
* 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');
@@ -23,6 +23,9 @@ const {
log
} = require('../lib/utils');
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
/**
* Extract a meaningful summary from the session transcript.
* Reads the JSONL transcript and pulls out key information:
@@ -167,16 +170,28 @@ async function main() {
log(`[SessionEnd] Failed to update timestamp in ${sessionFile}`);
}
// If we have a new summary, update the session file content
// 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) {
const existing = readFile(sessionFile);
if (existing) {
// Use a flexible regex that matches both "## Session Summary" and "## Current State"
// Match to end-of-string to avoid duplicate ### Stats sections
const updatedContent = existing.replace(
/## (?:Session Summary|Current State)[\s\S]*?$/ ,
buildSummarySection(summary).trim() + '\n'
);
const summaryBlock = buildSummaryBlock(summary);
let updatedContent = existing;
if (existing.includes(SUMMARY_START_MARKER) && existing.includes(SUMMARY_END_MARKER)) {
updatedContent = existing.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 = existing.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`
);
}
writeFile(sessionFile, updatedContent);
}
}
@@ -185,7 +200,7 @@ async function main() {
} else {
// Create new session file
const summarySection = summary
? buildSummarySection(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 = `# Session: ${today}
@@ -234,3 +249,10 @@ function buildSummarySection(summary) {
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, '\\$&');
}

74
scripts/lib/hook-flags.js Normal file
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

@@ -16,10 +16,12 @@ const {
log
} = require('./utils');
// Session filename pattern: YYYY-MM-DD-[short-id]-session.tmp
// The short-id is optional (old format) and can be 8+ alphanumeric characters
// Matches: "2026-02-01-session.tmp" or "2026-02-01-a1b2c3d4-session.tmp"
const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-z0-9]{8,}))?-session\.tmp$/;
// Session filename pattern: YYYY-MM-DD-[session-id]-session.tmp
// The session-id is optional (old format) and can include lowercase
// alphanumeric characters and hyphens, with a minimum length of 8.
// Matches: "2026-02-01-session.tmp", "2026-02-01-a1b2c3d4-session.tmp",
// and "2026-02-01-frontend-worktree-1-session.tmp"
const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-z0-9-]{8,}))?-session\.tmp$/;
/**
* Parse session filename to extract metadata